Post-repair hardware validation pipeline for Proxmox cluster hosts. Go orchestrator + in-image agent + mkosi live image + bundled dnsmasq PXE + SQLite + HTMX/SSE UI + notify registry + janitor + full docs.
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
// Package logs owns per-run flat-file logs and their live SSE fan-out.
|
||||
// A single Writer serialises writes for one run; a Hub keeps a cache
|
||||
// per run so handlers can open/close freely without stepping on each
|
||||
// other. Lines go to disk for persistence (reload + replay) and onto
|
||||
// the events.Hub so the UI tile can tail live.
|
||||
package logs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"vetting/internal/events"
|
||||
)
|
||||
|
||||
type Line struct {
|
||||
TS time.Time
|
||||
Level string // info|warn|error|debug
|
||||
Text string
|
||||
}
|
||||
|
||||
type Writer struct {
|
||||
runID int64
|
||||
mu sync.Mutex
|
||||
f *os.File
|
||||
hub *events.Hub
|
||||
}
|
||||
|
||||
// Hub owns the per-run Writers. The orchestrator creates one Hub at
|
||||
// startup and hands it to the api package.
|
||||
type Hub struct {
|
||||
dir string
|
||||
events *events.Hub
|
||||
mu sync.Mutex
|
||||
writers map[int64]*Writer
|
||||
}
|
||||
|
||||
func NewHub(dir string, ev *events.Hub) (*Hub, error) {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("mkdir log dir: %w", err)
|
||||
}
|
||||
return &Hub{dir: dir, events: ev, writers: map[int64]*Writer{}}, nil
|
||||
}
|
||||
|
||||
// WriterFor returns a cached Writer, opening the file lazily. The file
|
||||
// is append-only; if an existing run's log is reopened (e.g. after a
|
||||
// restart) we append rather than truncate so nothing is lost.
|
||||
func (h *Hub) WriterFor(runID int64) (*Writer, error) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if w, ok := h.writers[runID]; ok {
|
||||
return w, nil
|
||||
}
|
||||
path := filepath.Join(h.dir, fmt.Sprintf("run-%d.log", runID))
|
||||
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open %s: %w", path, err)
|
||||
}
|
||||
w := &Writer{runID: runID, f: f, hub: h.events}
|
||||
h.writers[runID] = w
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// Close flushes and closes all open run files. Called from main on
|
||||
// shutdown so the logs aren't left with buffered data.
|
||||
func (h *Hub) Close() {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
for id, w := range h.writers {
|
||||
if err := w.Close(); err != nil {
|
||||
log.Printf("logs: close run-%d: %v", id, err)
|
||||
}
|
||||
}
|
||||
h.writers = nil
|
||||
}
|
||||
|
||||
// PathFor returns the on-disk path for a run's log; used by replay
|
||||
// handlers and the report generator.
|
||||
func (h *Hub) PathFor(runID int64) string {
|
||||
return filepath.Join(h.dir, fmt.Sprintf("run-%d.log", runID))
|
||||
}
|
||||
|
||||
// Append writes a line to disk and publishes an SSE event. Failures
|
||||
// on disk log but don't block the SSE fan-out — the operator can still
|
||||
// see the live tail even if disk IO is degraded.
|
||||
func (w *Writer) Append(line Line) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if line.TS.IsZero() {
|
||||
line.TS = time.Now().UTC()
|
||||
}
|
||||
if line.Level == "" {
|
||||
line.Level = "info"
|
||||
}
|
||||
stamped := fmt.Sprintf("%s %5s %s\n", line.TS.Format(time.RFC3339Nano), strings.ToUpper(line.Level), line.Text)
|
||||
if _, err := w.f.WriteString(stamped); err != nil {
|
||||
log.Printf("logs: write run-%d: %v", w.runID, err)
|
||||
}
|
||||
if w.hub != nil {
|
||||
w.hub.Publish(events.Event{
|
||||
Name: fmt.Sprintf("log-%d", w.runID),
|
||||
Payload: renderLogSSE(line),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Writer) Close() error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.f == nil {
|
||||
return nil
|
||||
}
|
||||
err := w.f.Close()
|
||||
w.f = nil
|
||||
return err
|
||||
}
|
||||
|
||||
// renderLogSSE returns an HTMX-compatible fragment. The tile contains
|
||||
// a <div id="log-N" hx-swap-oob="beforeend">: each event appends one
|
||||
// <div class="log-line log-LEVEL"> to it.
|
||||
func renderLogSSE(l Line) string {
|
||||
level := strings.ToLower(l.Level)
|
||||
return fmt.Sprintf(
|
||||
`<div class="log-line log-%s">%s %s</div>`,
|
||||
html.EscapeString(level),
|
||||
html.EscapeString(l.TS.Format("15:04:05")),
|
||||
html.EscapeString(l.Text),
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user