package report import ( "log" "strings" "sync" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/metrics" ) // On-demand log tail (pull-based, same ACK-flag pattern as escrow/config-refresh): // the hub stores a pending request per (customer, app); the report ACK advertises the // requested app names in log_tail_requests; the NEXT report carries the collected tails // in log_tails. The hub clears the pending request when a tail arrives (consume-once) — // the controller listens to no one, it only pushes on its own cycle. const ( logTailLines = 200 logTailMaxLineChars = 400 logTailMaxBytes = 64 * 1024 // per app; head-truncated (newest lines kept) ) // LogTail is one app's on-demand ordered log tail riding the report. type LogTail struct { App string `json:"app"` CollectedAt time.Time `json:"collected_at"` Lines []string `json:"lines"` } var ( pendingTailsMu sync.Mutex pendingTails []string ) // SetPendingLogTails records the hub-requested apps from the latest ACK. The hub is // the source of truth: an ACK without requests clears any stale local pending set. func SetPendingLogTails(apps []string) { pendingTailsMu.Lock() defer pendingTailsMu.Unlock() pendingTails = append([]string(nil), apps...) } // drainPendingLogTails takes and CLEARS the pending set (consume-once controller-side). // If the subsequent push fails, the hub's request is still pending and the next ACK // re-arms it — fail-safe retry, no duplicate shipping. func drainPendingLogTails() []string { pendingTailsMu.Lock() defer pendingTailsMu.Unlock() apps := pendingTails pendingTails = nil return apps } // buildLogTailsSection collects the requested tails via fetch (seam: stacks.GetLogs or // metrics.FetchContainerLogTail). Lines stay ordered as emitted; each line is truncated // to 400 chars and redacted; per-app total is capped at 64KB keeping the NEWEST lines. // A failed fetch is skipped (logged) — the hub request stays pending and retries next cycle. func buildLogTailsSection(apps []string, fetch func(app string, lines int) (string, error), logger *log.Logger) []LogTail { var tails []LogTail for _, app := range apps { raw, err := fetch(app, logTailLines) if err != nil { if logger != nil { logger.Printf("[WARN] [report] log-tail fetch for %s failed: %v", app, err) } continue } lines := capTailLines(strings.Split(raw, "\n")) tails = append(tails, LogTail{App: app, CollectedAt: time.Now().UTC(), Lines: lines}) if logger != nil { logger.Printf("[INFO] [report] log-tail collected for %s: %d lines", app, len(lines)) } } return tails } // capTailLines truncates each line to logTailMaxLineChars, redacts it, and enforces the // per-app byte budget by dropping from the HEAD (oldest) so the newest lines survive. func capTailLines(lines []string) []string { // Drop a trailing empty split artifact. for len(lines) > 0 && lines[len(lines)-1] == "" { lines = lines[:len(lines)-1] } out := make([]string, 0, len(lines)) for _, l := range lines { l = metrics.RedactLine(truncateTailLine(l, logTailMaxLineChars)) out = append(out, l) } // Head-truncate to the byte budget: walk from the end accumulating. total := 0 start := len(out) for i := len(out) - 1; i >= 0; i-- { total += len(out[i]) + 1 // +1 for the newline it represents if total > logTailMaxBytes { break } start = i } return out[start:] } func truncateTailLine(s string, max int) string { runes := []rune(s) if len(runes) <= max { return s } return string(runes[:max]) + "…" }