// Package config handles loading and parsing the p4lf configuration file. // // The config file uses a simple KEY=VALUE format (one per line). // Lines beginning with '#' are comments. Blank lines are ignored. // The file is watched for modification time changes; callers should // reload by calling Load again when they detect a change. package config import ( "bufio" "fmt" "os" "regexp" "strconv" "strings" "time" ) // durationRe matches values like 60s, 3m, 1h. var durationRe = regexp.MustCompile(`^(\d+)([hms])$`) // sizeRe matches values like 500M, 3G, 1T, or plain integers (bytes). var sizeRe = regexp.MustCompile(`^(\d+)([KMGT]?)$`) // percentRe matches values like 3%. var percentRe = regexp.MustCompile(`^(\d+(?:\.\d+)?)%$`) // SpaceSpec represents a MinLogSpace value — either a percentage or an absolute size in bytes. type SpaceSpec struct { IsPercent bool Percent float64 Bytes int64 } func (s SpaceSpec) String() string { if s.IsPercent { return fmt.Sprintf("%.1f%%", s.Percent) } return fmt.Sprintf("%d bytes", s.Bytes) } // Config holds all p4lf configuration values. type Config struct { // P4LogFile is the path to the Perforce server log to tail. // Default: $P4LOG (from environment); required if not set. P4LogFile string // LogTailDelay is how often to flush a log chunk. Default: 60s. LogTailDelay time.Duration // LogChunksDir is where compressed chunk files are written. // Default: $LOGS/logchunks. LogChunksDir string // MaxLogChunks is the maximum number of un-consumed chunk files // allowed before pausing capture. 0 = unlimited. Default: 0. MaxLogChunks int // MinLogSpace is the minimum free space required in LogChunksDir // before pausing capture. Zero value means no check. Default: none. MinLogSpace SpaceSpec // MaxLogSize is the maximum size of p4lf's own log before it is // rotated. 0 = no rotation. Default: 100M. MaxLogSize int64 // MaxRotationRecoverySize is the maximum size of a freshly rotated-in // P4LOG before we give up reading from the beginning and warn instead. // 0 = always read from beginning. Default: 500M. MaxRotationRecoverySize int64 // StateFile is the path for the persistent state file (inode + offset). // Default: $LOGS/p4lf.state. StateFile string // ReadFromStart controls behaviour on first run (no state file). // true = read P4LOG from byte 0; false = start from EOF. Default: true. ReadFromStart bool // Debug: 0 = off, 1 = on, 2 = pedantic. Default: 0. Debug int } // Defaults returns a Config populated with default values. // Paths that depend on $LOGS or $P4LOG are resolved at this point. func Defaults() Config { logsDir := os.Getenv("LOGS") if logsDir == "" { logsDir = "/tmp" } return Config{ P4LogFile: os.Getenv("P4LOG"), LogTailDelay: 60 * time.Second, LogChunksDir: logsDir + "/logchunks", MaxLogChunks: 0, MaxLogSize: 100 * 1024 * 1024, MaxRotationRecoverySize: 500 * 1024 * 1024, StateFile: logsDir + "/p4lf.state", ReadFromStart: true, Debug: 0, } } // Load reads the config file at path, starting from defaults, and returns // the populated Config. func Load(path string) (Config, error) { cfg := Defaults() f, err := os.Open(path) if err != nil { return cfg, fmt.Errorf("opening config file %q: %w", path, err) } defer f.Close() scanner := bufio.NewScanner(f) lineNum := 0 for scanner.Scan() { lineNum++ line := strings.TrimSpace(scanner.Text()) if line == "" || strings.HasPrefix(line, "#") { continue } eq := strings.IndexByte(line, '=') if eq < 0 { return cfg, fmt.Errorf("config line %d: no '=' found: %q", lineNum, line) } key := strings.TrimSpace(line[:eq]) val := strings.TrimSpace(line[eq+1:]) // Strip inline comments. if idx := strings.Index(val, " #"); idx >= 0 { val = strings.TrimSpace(val[:idx]) } if err := applyKey(&cfg, key, val, lineNum); err != nil { return cfg, err } } if err := scanner.Err(); err != nil { return cfg, fmt.Errorf("reading config file: %w", err) } if cfg.P4LogFile == "" { return cfg, fmt.Errorf("P4LogFile is required (set in config or via $P4LOG environment variable)") } return cfg, nil } func applyKey(cfg *Config, key, val string, line int) error { switch key { case "P4LogFile": cfg.P4LogFile = val case "LogTailDelay": d, err := parseDuration(val) if err != nil { return fmt.Errorf("config line %d: LogTailDelay: %w", line, err) } cfg.LogTailDelay = d case "LogChunksDir": cfg.LogChunksDir = val case "MaxLogChunks": n, err := strconv.Atoi(val) if err != nil || n < 0 { return fmt.Errorf("config line %d: MaxLogChunks must be a non-negative integer", line) } cfg.MaxLogChunks = n case "MinLogSpace": sp, err := parseSpaceSpec(val) if err != nil { return fmt.Errorf("config line %d: MinLogSpace: %w", line, err) } cfg.MinLogSpace = sp case "MaxLogSize": sz, err := parseSize(val) if err != nil { return fmt.Errorf("config line %d: MaxLogSize: %w", line, err) } cfg.MaxLogSize = sz case "MaxRotationRecoverySize": sz, err := parseSize(val) if err != nil { return fmt.Errorf("config line %d: MaxRotationRecoverySize: %w", line, err) } cfg.MaxRotationRecoverySize = sz case "StateFile": cfg.StateFile = val case "ReadFromStart": b, err := parseBool(val) if err != nil { return fmt.Errorf("config line %d: ReadFromStart: %w", line, err) } cfg.ReadFromStart = b case "Debug": n, err := strconv.Atoi(val) if err != nil || n < 0 { return fmt.Errorf("config line %d: Debug must be a non-negative integer", line) } cfg.Debug = n default: return fmt.Errorf("config line %d: unknown key %q", line, key) } return nil } func parseDuration(s string) (time.Duration, error) { m := durationRe.FindStringSubmatch(s) if m == nil { // Also accept plain seconds integer. if n, err := strconv.Atoi(s); err == nil { return time.Duration(n) * time.Second, nil } return 0, fmt.Errorf("invalid duration %q (expected e.g. 60s, 3m, 1h)", s) } n, _ := strconv.Atoi(m[1]) switch m[2] { case "s": return time.Duration(n) * time.Second, nil case "m": return time.Duration(n) * time.Minute, nil case "h": return time.Duration(n) * time.Hour, nil } return 0, fmt.Errorf("invalid duration unit in %q", s) } func parseSize(s string) (int64, error) { if s == "0" || s == "None" || s == "none" { return 0, nil } m := sizeRe.FindStringSubmatch(strings.ToUpper(s)) if m == nil { return 0, fmt.Errorf("invalid size %q (expected e.g. 500M, 3G, 1T or plain bytes)", s) } n, _ := strconv.ParseInt(m[1], 10, 64) switch m[2] { case "K": return n * 1024, nil case "M": return n * 1024 * 1024, nil case "G": return n * 1024 * 1024 * 1024, nil case "T": return n * 1024 * 1024 * 1024 * 1024, nil default: return n, nil } } func parseSpaceSpec(s string) (SpaceSpec, error) { if s == "0" || strings.EqualFold(s, "none") || s == "" { return SpaceSpec{}, nil } if m := percentRe.FindStringSubmatch(s); m != nil { pct, _ := strconv.ParseFloat(m[1], 64) return SpaceSpec{IsPercent: true, Percent: pct}, nil } sz, err := parseSize(s) if err != nil { return SpaceSpec{}, err } return SpaceSpec{Bytes: sz}, nil } func parseBool(s string) (bool, error) { switch strings.ToLower(s) { case "true", "yes", "1", "on": return true, nil case "false", "no", "0", "off": return false, nil } return false, fmt.Errorf("invalid boolean %q (expected true/false/yes/no/1/0)", s) }