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)

View File

@@ -182,6 +182,81 @@ func TestEndToEndPushAndReplay(t *testing.T) {
}
}
func TestReloadRejectsImmutableChangesAndKeepsOldConfig(t *testing.T) {
oldCfg := &config.Config{
AgentID: "a1",
Hostname: "h",
StateDir: "/tmp/old",
Server: config.ServerConfig{
Enabled: boolPtr(true),
URL: "http://old",
HeartbeatInterval: config.Duration{Duration: 10 * time.Second},
BatchInterval: config.Duration{Duration: 10 * time.Second},
},
Metrics: config.MetricsConfig{Enabled: false},
Checks: []config.CheckConfig{{
ID: "c1",
Command: "true",
Interval: config.Duration{Duration: time.Second},
Timeout: config.Duration{Duration: time.Second},
}},
}
newCfg := *oldCfg
newCfg.Server.URL = "http://new"
a := &App{
Cfg: oldCfg,
AgentID: "a1",
Metrics: metrics.New(),
}
if err := a.Reload(&newCfg, "a1"); err == nil {
t.Fatal("expected immutable reload rejection")
}
if a.config().Server.URL != "http://old" {
t.Fatalf("old config was replaced: %+v", a.config().Server)
}
}
func TestReloadAcceptsLabelsAndChecks(t *testing.T) {
oldCfg := &config.Config{
AgentID: "a1",
Hostname: "h",
StateDir: "/tmp/old",
Labels: map[string]string{"env": "old"},
Server: config.ServerConfig{
Enabled: boolPtr(true),
URL: "http://server",
HeartbeatInterval: config.Duration{Duration: 10 * time.Second},
BatchInterval: config.Duration{Duration: 10 * time.Second},
},
Metrics: config.MetricsConfig{Enabled: false},
Checks: []config.CheckConfig{{
ID: "c1",
Command: "true",
Interval: config.Duration{Duration: time.Second},
Timeout: config.Duration{Duration: time.Second},
}},
}
newCfg := *oldCfg
newCfg.Labels = map[string]string{"env": "new"}
newCfg.Checks = []config.CheckConfig{{
ID: "c2",
Command: "true",
Interval: config.Duration{Duration: 2 * time.Second},
Timeout: config.Duration{Duration: time.Second},
}}
a := &App{
Cfg: oldCfg,
AgentID: "a1",
Metrics: metrics.New(),
}
if err := a.Reload(&newCfg, "a1"); err != nil {
t.Fatal(err)
}
if a.config().Labels["env"] != "new" || len(a.config().Checks) != 1 || a.config().Checks[0].ID != "c2" {
t.Fatalf("reload did not apply: %+v", a.config())
}
}
func boolPtr(v bool) *bool {
return &v
}