Add web auth, infinite-scroll, agent admission and review fixes across agent/server/web

This commit is contained in:
Stanislav Rossovskii
2026-05-28 23:45:12 +04:00
parent 37b1a1d6d6
commit 1026e9ebbe
75 changed files with 3021 additions and 916 deletions

View File

@@ -3,6 +3,7 @@ package app
import (
"context"
"errors"
"fmt"
"log/slog"
"sync"
"time"
@@ -24,6 +25,9 @@ type App struct {
Log *slog.Logger
lastDropLog time.Time
cfgMu sync.RWMutex
schedulerMu sync.RWMutex
scheduler *scheduler.Manager
}
const (
@@ -36,47 +40,61 @@ const (
func (a *App) Run(ctx context.Context) error {
events := make(chan scheduler.Event, 256)
cfg := a.config()
// Account drops accumulated during spool Open (e.g. shrunk limits on restart).
a.recordSpoolDrops(a.Spool.Drops())
a.Metrics.ChecksConfigured.Set(float64(len(a.Cfg.Checks)))
a.Metrics.BuildInfo.WithLabelValues(a.Version, boolLabel(a.Cfg.PushesToServer()), boolLabel(a.Cfg.ExposesMetrics())).Set(1)
for _, c := range a.Cfg.Checks {
a.Metrics.ConfigLoadSuccess.Set(1)
a.Metrics.ChecksConfigured.Set(float64(len(cfg.Checks)))
a.Metrics.BuildInfo.WithLabelValues(a.Version, boolLabel(cfg.PushesToServer()), boolLabel(cfg.ExposesMetrics())).Set(1)
for _, c := range cfg.Checks {
a.Metrics.CheckInterval.WithLabelValues(c.ID).Set(c.Interval.Duration.Seconds())
}
var wg sync.WaitGroup
if a.Cfg.ExposesMetrics() {
if cfg.ExposesMetrics() {
wg.Add(1)
go func() {
defer wg.Done()
if err := a.Metrics.Serve(ctx, a.Cfg.Metrics.Listen); err != nil {
if err := a.Metrics.Serve(ctx, cfg.Metrics.Listen); err != nil {
a.Log.Error("metrics server", "err", err)
}
}()
}
mgr := scheduler.NewManager(ctx, a.AgentID, events, scheduler.Hooks{
OnSkipped: func(id string) { a.Metrics.CheckSkipped.WithLabelValues(id).Inc() },
OnResult: func(ev scheduler.Event) {
a.Metrics.CheckRuns.WithLabelValues(ev.CheckID, ev.Status).Inc()
a.Metrics.CheckDuration.WithLabelValues(ev.CheckID).Observe(float64(ev.DurationMs) / 1000.0)
a.Metrics.CheckStatus.WithLabelValues(ev.CheckID).Set(metrics.StatusCode(ev.Status))
a.Metrics.CheckExitCode.WithLabelValues(ev.CheckID).Set(float64(ev.ExitCode))
ts := float64(ev.ObservedAt.Unix())
a.Metrics.CheckLastRun.WithLabelValues(ev.CheckID).Set(ts)
if ev.Status == "ok" {
a.Metrics.CheckLastSuccess.WithLabelValues(ev.CheckID).Set(ts)
}
},
OnEventChannelFull: func() { a.Metrics.EventsChannelFull.Inc() },
OnResourceLimitFailure: func(id string, resource string) {
a.Metrics.ResourceLimitApplyFailures.WithLabelValues(id, resource).Inc()
},
OnStopped: func(id string) { a.Metrics.DeleteCheck(id) },
})
mgr.Update(cfg.Checks)
a.schedulerMu.Lock()
a.scheduler = mgr
a.schedulerMu.Unlock()
wg.Add(1)
go func() {
defer wg.Done()
scheduler.Run(ctx, a.AgentID, a.Cfg.Checks, events, scheduler.Hooks{
OnSkipped: func(id string) { a.Metrics.CheckSkipped.WithLabelValues(id).Inc() },
OnResult: func(ev scheduler.Event) {
a.Metrics.CheckRuns.WithLabelValues(ev.CheckID, ev.Status).Inc()
a.Metrics.CheckDuration.WithLabelValues(ev.CheckID).Observe(float64(ev.DurationMs) / 1000.0)
a.Metrics.CheckStatus.WithLabelValues(ev.CheckID).Set(metrics.StatusCode(ev.Status))
a.Metrics.CheckExitCode.WithLabelValues(ev.CheckID).Set(float64(ev.ExitCode))
ts := float64(ev.ObservedAt.Unix())
a.Metrics.CheckLastRun.WithLabelValues(ev.CheckID).Set(ts)
if ev.Status == "ok" {
a.Metrics.CheckLastSuccess.WithLabelValues(ev.CheckID).Set(ts)
}
},
})
<-ctx.Done()
mgr.Stop()
mgr.Wait()
}()
if a.Cfg.PushesToServer() {
if cfg.PushesToServer() {
wg.Add(1)
go func() {
defer wg.Done()
@@ -128,18 +146,19 @@ func (a *App) spoolWriter(ctx context.Context, events <-chan scheduler.Event) {
}
func (a *App) heartbeatLoop(ctx context.Context) {
interval := a.Cfg.Server.HeartbeatInterval.Duration
attempt := 0
for {
cfg := a.config()
interval := cfg.Server.HeartbeatInterval.Duration
hb := client.HeartbeatRequest{
AgentID: a.AgentID,
ObservedAt: time.Now().UTC(),
Hostname: a.Cfg.Hostname,
Hostname: cfg.Hostname,
Features: client.AgentFeatures{
Push: a.Cfg.PushesToServer(),
Metrics: a.Cfg.ExposesMetrics(),
Push: cfg.PushesToServer(),
Metrics: cfg.ExposesMetrics(),
},
Labels: a.Cfg.HeartbeatLabels(a.Version),
Labels: cfg.HeartbeatLabels(a.Version),
}
a.Metrics.SendAttempts.WithLabelValues("heartbeat").Inc()
err := a.Client.SendHeartbeat(ctx, hb)
@@ -167,9 +186,14 @@ func (a *App) heartbeatLoop(ctx context.Context) {
}
func (a *App) sendLoop(ctx context.Context) {
interval := a.Cfg.Server.BatchInterval.Duration
attempt := 0
for {
select {
case <-ctx.Done():
return
default:
}
interval := a.config().Server.BatchInterval.Duration
items, err := a.Spool.Peek(client.MaxBatchEvents)
if err != nil {
a.Log.Error("spool peek", "err", err)
@@ -217,6 +241,74 @@ func (a *App) sendLoop(ctx context.Context) {
}
}
// Reload applies a validated hot-reload config if immutable fields match.
func (a *App) Reload(newCfg *config.Config, newAgentID string) error {
oldCfg := a.config()
if err := validateReload(oldCfg, newCfg, a.AgentID, newAgentID); err != nil {
a.RecordReloadFailure()
return err
}
a.cfgMu.Lock()
a.Cfg = newCfg
a.cfgMu.Unlock()
a.Metrics.ConfigLoadSuccess.Set(1)
a.Metrics.ConfigReloadTotal.WithLabelValues("success").Inc()
a.Metrics.ConfigLastReloadSuccess.Set(float64(time.Now().Unix()))
a.Metrics.ChecksConfigured.Set(float64(len(newCfg.Checks)))
for _, c := range newCfg.Checks {
a.Metrics.CheckInterval.WithLabelValues(c.ID).Set(c.Interval.Duration.Seconds())
}
a.schedulerMu.RLock()
mgr := a.scheduler
a.schedulerMu.RUnlock()
if mgr != nil {
mgr.Update(newCfg.Checks)
}
return nil
}
// RecordReloadFailure updates config reload metrics after a failed reload attempt.
func (a *App) RecordReloadFailure() {
a.Metrics.ConfigLoadSuccess.Set(0)
a.Metrics.ConfigReloadTotal.WithLabelValues("failure").Inc()
}
func (a *App) config() *config.Config {
a.cfgMu.RLock()
defer a.cfgMu.RUnlock()
return a.Cfg
}
func validateReload(oldCfg, newCfg *config.Config, oldAgentID, newAgentID string) error {
if oldAgentID != newAgentID {
return fmt.Errorf("agent_id change requires restart")
}
if oldCfg.StateDir != newCfg.StateDir {
return fmt.Errorf("state_dir change requires restart")
}
if oldCfg.PushesToServer() != newCfg.PushesToServer() {
return fmt.Errorf("server.enabled change requires restart")
}
if oldCfg.Server.URL != newCfg.Server.URL {
return fmt.Errorf("server.url change requires restart")
}
if oldCfg.Server.HeartbeatInterval.Duration != newCfg.Server.HeartbeatInterval.Duration {
return fmt.Errorf("server.heartbeat_interval change requires restart")
}
if oldCfg.Server.BatchInterval.Duration != newCfg.Server.BatchInterval.Duration {
return fmt.Errorf("server.batch_interval change requires restart")
}
if oldCfg.ExposesMetrics() != newCfg.ExposesMetrics() {
return fmt.Errorf("metrics.enabled change requires restart")
}
if oldCfg.Metrics.Listen != newCfg.Metrics.Listen {
return fmt.Errorf("metrics.listen change requires restart")
}
return nil
}
// sleep returns true if context was cancelled while waiting.
func sleep(ctx context.Context, d time.Duration) bool {
t := time.NewTimer(d)