forked from wrenn/wrenn
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.
This commit is contained in:
@ -1,3 +1,7 @@
|
|||||||
|
# Shared (applies to both control plane and host agent)
|
||||||
|
WRENN_DIR=/var/lib/wrenn
|
||||||
|
LOG_LEVEL=info
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
DATABASE_URL=postgres://wrenn:wrenn@localhost:5432/wrenn?sslmode=disable
|
DATABASE_URL=postgres://wrenn:wrenn@localhost:5432/wrenn?sslmode=disable
|
||||||
|
|
||||||
@ -9,7 +13,6 @@ WRENN_CP_LISTEN_ADDR=:9725
|
|||||||
|
|
||||||
# Host Agent
|
# Host Agent
|
||||||
WRENN_HOST_LISTEN_ADDR=:50051
|
WRENN_HOST_LISTEN_ADDR=:50051
|
||||||
WRENN_DIR=/var/lib/wrenn
|
|
||||||
WRENN_HOST_INTERFACE=eth0
|
WRENN_HOST_INTERFACE=eth0
|
||||||
WRENN_CP_URL=http://localhost:9725
|
WRENN_CP_URL=http://localhost:9725
|
||||||
WRENN_DEFAULT_ROOTFS_SIZE=5Gi
|
WRENN_DEFAULT_ROOTFS_SIZE=5Gi
|
||||||
|
|||||||
@ -21,6 +21,7 @@ import (
|
|||||||
"git.omukk.dev/wrenn/wrenn/internal/network"
|
"git.omukk.dev/wrenn/wrenn/internal/network"
|
||||||
"git.omukk.dev/wrenn/wrenn/internal/sandbox"
|
"git.omukk.dev/wrenn/wrenn/internal/sandbox"
|
||||||
"git.omukk.dev/wrenn/wrenn/pkg/auth"
|
"git.omukk.dev/wrenn/wrenn/pkg/auth"
|
||||||
|
"git.omukk.dev/wrenn/wrenn/pkg/logging"
|
||||||
"git.omukk.dev/wrenn/wrenn/proto/hostagent/gen/hostagentv1connect"
|
"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")
|
advertiseAddr := flag.String("address", "", "Externally-reachable address (ip:port) for this host agent")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
|
rootDir := envOrDefault("WRENN_DIR", "/var/lib/wrenn")
|
||||||
Level: slog.LevelDebug,
|
cleanupLog := logging.Setup(filepath.Join(rootDir, "logs"), "host-agent")
|
||||||
})))
|
defer cleanupLog()
|
||||||
|
|
||||||
if os.Geteuid() != 0 {
|
if os.Geteuid() != 0 {
|
||||||
slog.Error("host agent must run as root")
|
slog.Error("host agent must run as root")
|
||||||
@ -57,7 +58,6 @@ func main() {
|
|||||||
network.CleanupStaleNamespaces()
|
network.CleanupStaleNamespaces()
|
||||||
|
|
||||||
listenAddr := envOrDefault("WRENN_HOST_LISTEN_ADDR", ":50051")
|
listenAddr := envOrDefault("WRENN_HOST_LISTEN_ADDR", ":50051")
|
||||||
rootDir := envOrDefault("WRENN_DIR", "/var/lib/wrenn")
|
|
||||||
cpURL := os.Getenv("WRENN_CP_URL")
|
cpURL := os.Getenv("WRENN_CP_URL")
|
||||||
credsFile := filepath.Join(rootDir, "host-credentials.json")
|
credsFile := filepath.Join(rootDir, "host-credentials.json")
|
||||||
|
|
||||||
|
|||||||
19
deploy/logrotate/wrenn
Normal file
19
deploy/logrotate/wrenn
Normal file
@ -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
|
||||||
|
}
|
||||||
@ -14,6 +14,7 @@ type Config struct {
|
|||||||
RedisURL string
|
RedisURL string
|
||||||
ListenAddr string
|
ListenAddr string
|
||||||
JWTSecret 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
|
// 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).
|
// 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"),
|
RedisURL: envOrDefault("REDIS_URL", "redis://localhost:6379/0"),
|
||||||
ListenAddr: envOrDefault("WRENN_CP_LISTEN_ADDR", ":8080"),
|
ListenAddr: envOrDefault("WRENN_CP_LISTEN_ADDR", ":8080"),
|
||||||
JWTSecret: os.Getenv("JWT_SECRET"),
|
JWTSecret: os.Getenv("JWT_SECRET"),
|
||||||
|
WrennDir: envOrDefault("WRENN_DIR", "/var/lib/wrenn"),
|
||||||
|
|
||||||
CACert: os.Getenv("WRENN_CA_CERT"),
|
CACert: os.Getenv("WRENN_CA_CERT"),
|
||||||
CAKey: os.Getenv("WRENN_CA_KEY"),
|
CAKey: os.Getenv("WRENN_CA_KEY"),
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
@ -22,6 +23,7 @@ import (
|
|||||||
"git.omukk.dev/wrenn/wrenn/pkg/config"
|
"git.omukk.dev/wrenn/wrenn/pkg/config"
|
||||||
"git.omukk.dev/wrenn/wrenn/pkg/db"
|
"git.omukk.dev/wrenn/wrenn/pkg/db"
|
||||||
"git.omukk.dev/wrenn/wrenn/pkg/lifecycle"
|
"git.omukk.dev/wrenn/wrenn/pkg/lifecycle"
|
||||||
|
"git.omukk.dev/wrenn/wrenn/pkg/logging"
|
||||||
"git.omukk.dev/wrenn/wrenn/pkg/scheduler"
|
"git.omukk.dev/wrenn/wrenn/pkg/scheduler"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -39,11 +41,9 @@ func Run(opts ...Option) {
|
|||||||
opt(o)
|
opt(o)
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
|
|
||||||
Level: slog.LevelDebug,
|
|
||||||
})))
|
|
||||||
|
|
||||||
cfg := config.Load()
|
cfg := config.Load()
|
||||||
|
cleanupLog := logging.Setup(filepath.Join(cfg.WrennDir, "logs"), "control-plane")
|
||||||
|
defer cleanupLog()
|
||||||
|
|
||||||
if len(cfg.JWTSecret) < 32 {
|
if len(cfg.JWTSecret) < 32 {
|
||||||
slog.Error("JWT_SECRET must be at least 32 characters")
|
slog.Error("JWT_SECRET must be at least 32 characters")
|
||||||
|
|||||||
135
pkg/logging/logging.go
Normal file
135
pkg/logging/logging.go
Normal file
@ -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()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user