diff --git a/cmd/maintenant/main.go b/cmd/maintenant/main.go index c1988aef..4ebd3795 100644 --- a/cmd/maintenant/main.go +++ b/cmd/maintenant/main.go @@ -46,8 +46,55 @@ func main() { args = args[1:] } - // --help and --version answer before the logger exists, so slog JSON does - // not land in the middle of human-readable output. + answerHelpOrVersion(args) + cfg, visited := loadConfig(args) + + logOutput := os.Stdout + if mcpStdio { + logOutput = os.Stderr + } + logger := slog.New(slog.NewJSONHandler(logOutput, &slog.HandlerOptions{ + Level: slogLevelFrom(cfg.LogLevel), + })) + if mcpStdio { + runMCPStdio(cfg, logger) + return + } + + if cfg.MultiHost.InsecureSkipVerify { + logger.Warn("insecure TLS verification disabled — do not use in production") + } + logger.Info("maintenant starting", "version", version, "commit", commit, "build_date", buildDate, "mode", cfg.Mode) + if cfg.DemoMode { + logger.Info("demo mode enabled: instance is read-only") + } + + validateStartup(cfg, logger) + + // --copy-store-to runs the copy and exits: the binary has + // no subcommands and this feature does not introduce any. + if target := visited["copy-store-to"]; target != "" { + if cfg.Mode == "agent" { + logger.Error("--copy-store-to is not accepted in agent mode", + "fix", "an agent has no server data set to carry") + os.Exit(copyExitAgentBad) + } + os.Exit(runCopy(cfg.DBPath, target, visited["yes"] == "true", os.Stdout, os.Stdin, logger)) + } + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + if cfg.Mode == "agent" { + runAgent(ctx, cfg, logger) + return + } + runServer(ctx, cfg, logger) +} + +// answerHelpOrVersion prints --help or --version and exits, before the logger +// exists, so slog JSON does not land in the middle of human-readable output. +func answerHelpOrVersion(args []string) { for _, arg := range args { switch arg { case "--help", "-h": @@ -59,7 +106,10 @@ func main() { os.Exit(0) } } +} +// loadConfig builds the configuration from the environment then the flags, exiting 2 on an unknown flag or a bad value. +func loadConfig(args []string) (app.Config, map[string]string) { // One parser for every option: app.Registry holds the flag, its environment // variable and how it lands in Config, so --help cannot advertise a flag the // binary would then refuse. Unknown flag or bad value exits 2. @@ -80,37 +130,25 @@ func main() { fmt.Fprintf(os.Stderr, "error: %v\n", err) os.Exit(2) } + return cfg, visited +} - logOutput := os.Stdout - if mcpStdio { - logOutput = os.Stderr - } - logger := slog.New(slog.NewJSONHandler(logOutput, &slog.HandlerOptions{ - Level: slogLevelFrom(cfg.LogLevel), - })) - if mcpStdio { - if cfg.Mode == "agent" { - logger.Error("--mcp-stdio is not accepted in agent mode", "fix", "run it against the server") - os.Exit(2) - } - ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) - err := mcp.Relay(ctx, mcp.SocketPath(cfg.DBPath), os.Stdin, os.Stdout) - stop() - if err != nil { - logger.Error("MCP stdio relay failed", "error", err) - os.Exit(1) - } - return - } - - if cfg.MultiHost.InsecureSkipVerify { - logger.Warn("insecure TLS verification disabled — do not use in production") +func runMCPStdio(cfg app.Config, logger *slog.Logger) { + if cfg.Mode == "agent" { + logger.Error("--mcp-stdio is not accepted in agent mode", "fix", "run it against the server") + os.Exit(2) } - logger.Info("maintenant starting", "version", version, "commit", commit, "build_date", buildDate, "mode", cfg.Mode) - if cfg.DemoMode { - logger.Info("demo mode enabled: instance is read-only") + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + err := mcp.Relay(ctx, mcp.SocketPath(cfg.DBPath), os.Stdin, os.Stdout) + stop() + if err != nil { + logger.Error("MCP stdio relay failed", "error", err) + os.Exit(1) } +} +// validateStartup loads the extra CA bundle and checks the configuration, exiting 1 on the first failure. +func validateStartup(cfg app.Config, logger *slog.Logger) { // Loaded before anything can probe: both server and agent modes run from // here, and a bad path must stop the process rather than turn every TLS // check into a misleading "unknown authority". @@ -161,52 +199,39 @@ func main() { logger.Error("invalid agent spool configuration", "error", err) os.Exit(1) } +} - // --copy-store-to runs the copy and exits: the binary has - // no subcommands and this feature does not introduce any. - if target := visited["copy-store-to"]; target != "" { - if cfg.Mode == "agent" { - logger.Error("--copy-store-to is not accepted in agent mode", - "fix", "an agent has no server data set to carry") - os.Exit(copyExitAgentBad) - } - os.Exit(runCopy(cfg.DBPath, target, visited["yes"] == "true", os.Stdout, os.Stdin, logger)) +func runAgent(ctx context.Context, cfg app.Config, logger *slog.Logger) { + dataDir := os.Getenv("MAINTENANT_DATA_DIR") + if dataDir == "" { + dataDir = defaultAgentDataDir } - - ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) - defer stop() - - if cfg.Mode == "agent" { - dataDir := os.Getenv("MAINTENANT_DATA_DIR") - if dataDir == "" { - dataDir = defaultAgentDataDir - } - agentCfg := agent.AgentConfig{ - DataDir: dataDir, - ServerURL: cfg.MultiHost.ServerURL, - EnrollmentToken: cfg.MultiHost.EnrollmentToken, - RuntimeOverride: cfg.MultiHost.RuntimeOverride, - Label: cfg.MultiHost.Label, - NodeName: cfg.MultiHost.NodeName, - AgentVersion: version, - InsecureSkipVerify: cfg.MultiHost.InsecureSkipVerify, - ServerFingerprint: cfg.MultiHost.ServerFingerprint, - ProxyLabels: cfg.ProxyLabels, - SpoolMaxMemoryBytes: cfg.MultiHost.AgentSpoolMaxMemoryBytes, - SpoolMaxDiskBytes: cfg.MultiHost.AgentSpoolMaxDiskBytes, - SpoolMaxAgeSeconds: cfg.MultiHost.AgentSpoolMaxAgeSeconds, - } - if window := int64(resource.DefaultSnapshotRetention.Seconds()); agentCfg.SpoolMaxAgeSeconds > window { - logger.Warn("agent spool max age is past the server raw-sample window, older events are replayed after their purge", - "maxAgeSeconds", agentCfg.SpoolMaxAgeSeconds, "windowSeconds", window) - } - if err := agent.Run(ctx, agentCfg, logger); err != nil { - logger.Error("agent run failed", "error", err) - os.Exit(1) - } - return + agentCfg := agent.AgentConfig{ + DataDir: dataDir, + ServerURL: cfg.MultiHost.ServerURL, + EnrollmentToken: cfg.MultiHost.EnrollmentToken, + RuntimeOverride: cfg.MultiHost.RuntimeOverride, + Label: cfg.MultiHost.Label, + NodeName: cfg.MultiHost.NodeName, + AgentVersion: version, + InsecureSkipVerify: cfg.MultiHost.InsecureSkipVerify, + ServerFingerprint: cfg.MultiHost.ServerFingerprint, + ProxyLabels: cfg.ProxyLabels, + SpoolMaxMemoryBytes: cfg.MultiHost.AgentSpoolMaxMemoryBytes, + SpoolMaxDiskBytes: cfg.MultiHost.AgentSpoolMaxDiskBytes, + SpoolMaxAgeSeconds: cfg.MultiHost.AgentSpoolMaxAgeSeconds, + } + if window := int64(resource.DefaultSnapshotRetention.Seconds()); agentCfg.SpoolMaxAgeSeconds > window { + logger.Warn("agent spool max age is past the server raw-sample window, older events are replayed after their purge", + "maxAgeSeconds", agentCfg.SpoolMaxAgeSeconds, "windowSeconds", window) } + if err := agent.Run(ctx, agentCfg, logger); err != nil { + logger.Error("agent run failed", "error", err) + os.Exit(1) + } +} +func runServer(ctx context.Context, cfg app.Config, logger *slog.Logger) { commercial.Register() application, err := app.New(cfg, logger, app.WithExtensions(commercial.Extensions())) if err != nil { diff --git a/internal/agent/client.go b/internal/agent/client.go index d84114d1..3f5b2fa8 100644 --- a/internal/agent/client.go +++ b/internal/agent/client.go @@ -103,13 +103,6 @@ func NewClientWithFingerprint(ctx context.Context, serverURL string, fingerprint }, nil } -// NewClient dials the server at serverURL and returns a ready-to-use Client. -// serverURL should start with "grpcs://" (TLS) or "grpc://" (plaintext, not recommended). -// If insecureSkipVerify is true, TLS certificate validation is skipped (debug only). -func NewClient(ctx context.Context, serverURL string, insecureSkipVerify bool, logger *slog.Logger) (*Client, error) { - return NewClientWithFingerprint(ctx, serverURL, "", insecureSkipVerify, logger) -} - func normalizeFingerprint(fp string) (string, error) { fp = strings.ToLower(strings.ReplaceAll(fp, ":", "")) if fp == "" { diff --git a/internal/agent/commands.go b/internal/agent/commands.go index b01dc989..900b7175 100644 --- a/internal/agent/commands.go +++ b/internal/agent/commands.go @@ -92,13 +92,6 @@ func (r *CommandRunner) cancel(requestID string) { } } -// inFlight reports how many commands are currently running. -func (r *CommandRunner) inFlight() int { - r.mu.Lock() - defer r.mu.Unlock() - return len(r.running) -} - // CancelAll aborts every in-flight command, used when the stream goes away. func (r *CommandRunner) CancelAll() { r.mu.Lock() diff --git a/internal/agent/commands_test.go b/internal/agent/commands_test.go index 82dc459a..6d7192d7 100644 --- a/internal/agent/commands_test.go +++ b/internal/agent/commands_test.go @@ -267,3 +267,9 @@ func TestClampLogLines(t *testing.T) { func TestCapabilities_AdvertisesLogs(t *testing.T) { require.Contains(t, Capabilities(), CapabilityLogs) } + +func (r *CommandRunner) inFlight() int { + r.mu.Lock() + defer r.mu.Unlock() + return len(r.running) +} diff --git a/internal/agent/refusal_test.go b/internal/agent/refusal_test.go index eca6f819..056bcccd 100644 --- a/internal/agent/refusal_test.go +++ b/internal/agent/refusal_test.go @@ -92,7 +92,7 @@ func startIngest(t *testing.T, srv agentpb.IngestServer, cert *tls.Certificate) func dialIngest(t *testing.T, url string) *Client { t.Helper() - c, err := NewClient(context.Background(), url, false, quietLogger()) + c, err := NewClientWithFingerprint(context.Background(), url, "", false, quietLogger()) require.NoError(t, err) t.Cleanup(func() { _ = c.Close() }) return c diff --git a/internal/agent/spool_store.go b/internal/agent/spool_store.go index 23a95fed..26248396 100644 --- a/internal/agent/spool_store.go +++ b/internal/agent/spool_store.go @@ -250,18 +250,6 @@ func (s *spoolStore) Depth() (int64, error) { return n, nil } -// MinSeq returns the lowest queued sequence, or 0 when the spool is empty. -func (s *spoolStore) MinSeq() (int64, error) { - var seq sql.NullInt64 - if err := s.db.QueryRow(`SELECT MIN(seq) FROM spool_events`).Scan(&seq); err != nil { - return 0, fmt.Errorf("read lowest spool sequence: %w", err) - } - if !seq.Valid { - return 0, nil - } - return seq.Int64, nil -} - func (s *spoolStore) meta(key string) (int64, error) { var v int64 if err := s.db.QueryRow(`SELECT value FROM spool_meta WHERE key = ?`, key).Scan(&v); err != nil { diff --git a/internal/alert/alerts.go b/internal/alert/alerts.go index a26068fe..8d7c3b24 100644 --- a/internal/alert/alerts.go +++ b/internal/alert/alerts.go @@ -75,30 +75,3 @@ func (d *RestartDetector) Check(ctx context.Context, c *container.Container) (in AgentID: c.AgentID, }, nil } - -// HealthAlert represents a health status change alert. -type HealthAlert struct { - ContainerID string - ContainerName string - PreviousHealth *container.HealthStatus - NewHealth container.HealthStatus - Severity container.AlertSeverity - Timestamp time.Time -} - -// CheckHealthTransition returns an alert if a container transitions from healthy to unhealthy. -func CheckHealthTransition(c *container.Container, previousHealth *container.HealthStatus, newHealth container.HealthStatus) *HealthAlert { - // Only alert on healthy → unhealthy transition - if previousHealth == nil || *previousHealth != container.HealthHealthy || newHealth != container.HealthUnhealthy { - return nil - } - - return &HealthAlert{ - ContainerID: c.ID, - ContainerName: c.Name, - PreviousHealth: previousHealth, - NewHealth: newHealth, - Severity: c.AlertSeverity, - Timestamp: time.Now(), - } -} diff --git a/internal/alert/channel_patch.go b/internal/alert/channel_patch.go new file mode 100644 index 00000000..b0e43d7d --- /dev/null +++ b/internal/alert/channel_patch.go @@ -0,0 +1,79 @@ +// Copyright 2026 Benjamin Touchard (Kolapsis) +// SPDX-License-Identifier: Apache-2.0 + +package alert + +import "strings" + +// ChannelPatch is a partial update of a notification channel: a nil field keeps the stored value. +type ChannelPatch struct { + Name *string + Type *string + URL *string + Headers *string + Secret *string + Config *string + Enabled *bool +} + +// ChannelFieldError names the patch field that failed validation. +type ChannelFieldError struct { + Field string + Msg string +} + +func (e *ChannelFieldError) Error() string { return "field=" + e.Field + ": " + e.Msg } + +// NormalizeConfig trims a channel config and turns a JSON null into the empty string. +func NormalizeConfig(config string) string { + config = strings.TrimSpace(config) + if config == "null" { + return "" + } + return config +} + +// Extends reports whether the patch does more than switch the channel off, which never needs the edition (FR-001d). +func (p *ChannelPatch) Extends() bool { + return p.Name != nil || p.Type != nil || p.URL != nil || + p.Headers != nil || p.Secret != nil || p.Config != nil || + (p.Enabled != nil && *p.Enabled) +} + +// TouchesCredentials reports whether the patch changes the secret or the config. +func (p *ChannelPatch) TouchesCredentials() bool { return p.Secret != nil || p.Config != nil } + +// TouchesDestination reports whether the patch changes the URL or the type. +func (p *ChannelPatch) TouchesDestination() bool { return p.URL != nil || p.Type != nil } + +// Apply writes the patch onto ch, refusing invalid headers and an empty secret; an absent secret keeps the stored one (FR-006). +func (p *ChannelPatch) Apply(ch *NotificationChannel) error { + if p.Name != nil { + ch.Name = *p.Name + } + if p.Type != nil { + ch.Type = *p.Type + } + if p.URL != nil { + ch.URL = *p.URL + } + if p.Headers != nil { + if _, err := ParseHeaders(*p.Headers); err != nil { + return &ChannelFieldError{Field: "headers", Msg: err.Error()} + } + ch.Headers = *p.Headers + } + if p.Secret != nil { + if *p.Secret == "" { + return &ChannelFieldError{Field: "secret", Msg: "cannot be cleared; delete the channel instead"} + } + ch.Secret = *p.Secret + } + if p.Config != nil { + ch.Config = NormalizeConfig(*p.Config) + } + if p.Enabled != nil { + ch.Enabled = *p.Enabled + } + return nil +} diff --git a/internal/alert/channel_patch_test.go b/internal/alert/channel_patch_test.go new file mode 100644 index 00000000..d26453ad --- /dev/null +++ b/internal/alert/channel_patch_test.go @@ -0,0 +1,100 @@ +// Copyright 2026 Benjamin Touchard (Kolapsis) +// SPDX-License-Identifier: Apache-2.0 + +package alert + +import ( + "errors" + "testing" +) + +func TestChannelPatchApply(t *testing.T) { + tests := []struct { + name string + patch ChannelPatch + wantErr string + wantField string + check func(t *testing.T, ch *NotificationChannel) + }{ + { + name: "fields applied", + patch: ChannelPatch{Name: new("n"), Type: new("slack"), URL: new("u"), Headers: new(`{"A":"b"}`), Secret: new("s"), Enabled: new(false)}, + check: func(t *testing.T, ch *NotificationChannel) { + if ch.Name != "n" || ch.Type != "slack" || ch.URL != "u" || ch.Headers != `{"A":"b"}` || ch.Secret != "s" || ch.Enabled { + t.Fatalf("unexpected channel %+v", ch) + } + }, + }, + { + name: "absent secret keeps stored", + patch: ChannelPatch{Name: new("n")}, + check: func(t *testing.T, ch *NotificationChannel) { + if ch.Secret != "stored" || !ch.Enabled { + t.Fatalf("unexpected channel %+v", ch) + } + }, + }, + { + name: "invalid headers", + patch: ChannelPatch{Headers: new(`[1]`)}, + wantErr: "field=headers: " + errInvalidHeaders.Error(), + wantField: "headers", + }, + { + name: "empty secret refused", + patch: ChannelPatch{Secret: new("")}, + wantErr: "field=secret: cannot be cleared; delete the channel instead", + wantField: "secret", + }, + { + name: "config null becomes empty", + patch: ChannelPatch{Config: new(" null ")}, + check: func(t *testing.T, ch *NotificationChannel) { + if ch.Config != "" { + t.Fatalf("Config = %q", ch.Config) + } + }, + }, + { + name: "config trimmed", + patch: ChannelPatch{Config: new(` {"a":"b"} `)}, + check: func(t *testing.T, ch *NotificationChannel) { + if ch.Config != `{"a":"b"}` { + t.Fatalf("Config = %q", ch.Config) + } + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ch := &NotificationChannel{Secret: "stored", Config: "old", Enabled: true} + err := tc.patch.Apply(ch) + if tc.wantErr != "" { + var fe *ChannelFieldError + if !errors.As(err, &fe) || fe.Field != tc.wantField || err.Error() != tc.wantErr { + t.Fatalf("err = %v, want %q", err, tc.wantErr) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + tc.check(t, ch) + }) + } +} + +func TestChannelPatchTouches(t *testing.T) { + if (&ChannelPatch{Name: new("n"), Enabled: new(true)}).TouchesCredentials() { + t.Fatal("name and enabled do not touch credentials") + } + if !(&ChannelPatch{Secret: new("s")}).TouchesCredentials() || !(&ChannelPatch{Config: new("")}).TouchesCredentials() { + t.Fatal("secret and config touch credentials") + } + if (&ChannelPatch{Name: new("n"), Secret: new("s")}).TouchesDestination() { + t.Fatal("name and secret do not touch the destination") + } + if !(&ChannelPatch{URL: new("u")}).TouchesDestination() || !(&ChannelPatch{Type: new("t")}).TouchesDestination() { + t.Fatal("url and type touch the destination") + } +} diff --git a/internal/api/v1/agents_handler.go b/internal/api/v1/agents_handler.go index 2f609180..fdc483a0 100644 --- a/internal/api/v1/agents_handler.go +++ b/internal/api/v1/agents_handler.go @@ -167,10 +167,6 @@ func buildInstallTemplatesFor(serverURL, token, fingerprint string, selfSigned b } } -func buildInstallTemplates(serverURL, token string) map[string]string { - return buildInstallTemplatesFor(serverURL, token, "", false) -} - func buildInstallStandalone(serverURL, token, pin string) string { out := "curl -fsSL https://install.maintenant.dev | sudo bash -s -- \\\n" + " --mode agent \\\n" + diff --git a/internal/api/v1/alerts.go b/internal/api/v1/alerts.go index 8cabb85a..514ff7ab 100644 --- a/internal/api/v1/alerts.go +++ b/internal/api/v1/alerts.go @@ -10,7 +10,6 @@ import ( "log/slog" "net/http" "strconv" - "strings" "time" "github.com/kolapsis/maintenant/internal/alert" @@ -269,10 +268,7 @@ func (h *AlertHandler) HandleCreateChannel(w http.ResponseWriter, r *http.Reques return } - config := strings.TrimSpace(string(input.Config)) - if config == "null" { - config = "" - } + config := alert.NormalizeConfig(string(input.Config)) if v, ok := h.channelValidator(input.Type); ok { if err := v.ValidateCredentials(input.Secret, config); err != nil { WriteError(w, http.StatusBadRequest, "VALIDATION_ERROR", err.Error()) @@ -345,72 +341,36 @@ func (h *AlertHandler) HandleUpdateChannel(w http.ResponseWriter, r *http.Reques return } - // A request that only switches the channel off never needs the edition that - // opens the type: an operator whose licence expired must still be able to - // silence a channel, and deleting it to do so is not a way out (FR-001d). - extends := input.Name != nil || input.Type != nil || input.URL != nil || - input.Headers != nil || input.Secret != nil || input.Config != nil || - (input.Enabled != nil && *input.Enabled) - if extends { - // Both the type it has and the type it would become: a downgraded - // instance may neither keep editing a gated channel nor turn a plain - // one into a gated one. - if refuseChannelCapability(w, ch.Type) { - return - } - if input.Type != nil && refuseChannelCapability(w, *input.Type) { - return - } - } - - if input.Name != nil { - ch.Name = *input.Name - } - if input.Type != nil { - ch.Type = *input.Type + patch := alert.ChannelPatch{ + Name: input.Name, Type: input.Type, URL: input.URL, Headers: input.Headers, + Secret: input.Secret, Enabled: input.Enabled, } - if input.URL != nil { - ch.URL = *input.URL + if input.Config != nil { + patch.Config = new(string(*input.Config)) } - if input.Headers != nil { - if _, err := alert.ParseHeaders(*input.Headers); err != nil { - WriteError(w, http.StatusBadRequest, "INVALID_INPUT", err.Error()) + if patch.Extends() { + if refuseChannelCapability(w, ch.Type) { return } - ch.Headers = *input.Headers - } - // An absent secret keeps the stored one (FR-006); an empty one is refused, - // since a channel without its credential can no longer send and would say - // nothing about it. - if input.Secret != nil { - if *input.Secret == "" { - WriteError(w, http.StatusBadRequest, "VALIDATION_ERROR", - "secret cannot be cleared; delete the channel instead") + if patch.Type != nil && refuseChannelCapability(w, *patch.Type) { return } - ch.Secret = *input.Secret - } - if input.Config != nil { - config := strings.TrimSpace(string(*input.Config)) - if config == "null" { - config = "" - } - ch.Config = config } - if input.Enabled != nil { - ch.Enabled = *input.Enabled + + if err := patch.Apply(ch); err != nil { + writeChannelPatchError(w, err) + return } - if v, ok := h.channelValidator(ch.Type); ok && (input.Secret != nil || input.Config != nil) { + if v, ok := h.channelValidator(ch.Type); ok && patch.TouchesCredentials() { if err := v.ValidateCredentials(ch.Secret, ch.Config); err != nil { WriteError(w, http.StatusBadRequest, "VALIDATION_ERROR", err.Error()) return } } - // Re-validate the destination when the URL or type changed, so an update - // can't smuggle in a private/internal URL the create path would reject. - if input.URL != nil || input.Type != nil { + // An update must not slip in a private URL the create path would refuse. + if patch.TouchesDestination() { if err := h.validateChannelURL(r.Context(), ch.Type, ch.URL); err != nil { WriteError(w, http.StatusBadRequest, "VALIDATION_ERROR", err.Error()) return @@ -430,6 +390,19 @@ func (h *AlertHandler) HandleUpdateChannel(w http.ResponseWriter, r *http.Reques WriteJSON(w, http.StatusOK, stored) } +func writeChannelPatchError(w http.ResponseWriter, err error) { + var fe *alert.ChannelFieldError + if !errors.As(err, &fe) { + WriteError(w, http.StatusBadRequest, "VALIDATION_ERROR", err.Error()) + return + } + if fe.Field == "headers" { + WriteError(w, http.StatusBadRequest, "INVALID_INPUT", fe.Msg) + return + } + WriteError(w, http.StatusBadRequest, "VALIDATION_ERROR", fe.Field+" "+fe.Msg) +} + // HandleDeleteChannel handles DELETE /api/v1/channels/{id}. func (h *AlertHandler) HandleDeleteChannel(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") diff --git a/internal/api/v1/install_templates_test.go b/internal/api/v1/install_templates_test.go index 138379c5..e02a0338 100644 --- a/internal/api/v1/install_templates_test.go +++ b/internal/api/v1/install_templates_test.go @@ -27,7 +27,7 @@ func TestBuildInstallTemplates_ReturnsFourModes(t *testing.T) { token = "mnt_enr_testtokenabc123" ) - tpl := buildInstallTemplates(serverURL, token) + tpl := buildInstallTemplatesFor(serverURL, token, "", false) wantKeys := []string{"standalone", "docker_run", "docker_compose", "kubernetes"} for _, k := range wantKeys { diff --git a/internal/api/v1/status_personalization.go b/internal/api/v1/status_personalization.go index 9f5bc7fa..3b2b140b 100644 --- a/internal/api/v1/status_personalization.go +++ b/internal/api/v1/status_personalization.go @@ -54,8 +54,7 @@ func (h *PersonalizationHandler) HandlePutSettings(w http.ResponseWriter, r *htt Timezone string `json:"timezone"` DateFormat string `json:"date_format"` } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - WriteError(w, http.StatusBadRequest, "validation_error", "invalid JSON") + if !decodeJSON(w, r, &req) { return } @@ -93,34 +92,10 @@ func (h *PersonalizationHandler) HandlePutSettings(w http.ResponseWriter, r *htt resp := settingsToResponse(out) if len(warnings) > 0 { - type withWarnings struct { - Version int64 `json:"version"` - Title string `json:"title"` - Subtitle string `json:"subtitle"` - Colors settingsColorsResp `json:"colors"` - Announcement settingsAnnouncementResp `json:"announcement"` - FooterTextMD string `json:"footer_text_md"` - FooterTextHTML string `json:"footer_text_html"` - Locale string `json:"locale"` - Timezone string `json:"timezone"` - DateFormat string `json:"date_format"` - UpdatedAt string `json:"updated_at"` - Warnings map[string][]status.ContrastWarning `json:"warnings"` - } - WriteJSON(w, http.StatusOK, withWarnings{ - Version: out.Version, - Title: out.Title, - Subtitle: out.Subtitle, - Colors: resp.Colors, - Announcement: resp.Announcement, - FooterTextMD: out.FooterTextMD, - FooterTextHTML: out.FooterTextHTML, - Locale: out.Locale, - Timezone: out.Timezone, - DateFormat: out.DateFormat, - UpdatedAt: out.UpdatedAt.Format("2006-01-02T15:04:05Z"), - Warnings: map[string][]status.ContrastWarning{"contrast": warnings}, - }) + WriteJSON(w, http.StatusOK, struct { + settingsResp + Warnings map[string][]status.ContrastWarning `json:"warnings"` + }{resp, map[string][]status.ContrastWarning{"contrast": warnings}}) return } WriteJSON(w, http.StatusOK, resp) @@ -238,8 +213,7 @@ func (h *PersonalizationHandler) HandleCreateFooterLink(w http.ResponseWriter, r Label string `json:"label"` URL string `json:"url"` } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - WriteError(w, http.StatusBadRequest, "validation_error", "invalid JSON") + if !decodeJSON(w, r, &req) { return } link, err := h.svc.CreateFooterLink(r.Context(), req.Label, req.URL) @@ -252,44 +226,32 @@ func (h *PersonalizationHandler) HandleCreateFooterLink(w http.ResponseWriter, r } func (h *PersonalizationHandler) HandleUpdateFooterLink(w http.ResponseWriter, r *http.Request) { - id := r.PathValue("id") - if id == "" { - WriteError(w, http.StatusBadRequest, "validation_error", "invalid id") + id, ok := pathID(w, r) + if !ok { return } var req struct { Label string `json:"label"` URL string `json:"url"` } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - WriteError(w, http.StatusBadRequest, "validation_error", "invalid JSON") + if !decodeJSON(w, r, &req) { return } link, err := h.svc.UpdateFooterLink(r.Context(), id, req.Label, req.URL) if err != nil { - if errors.Is(err, status.ErrNotFound) { - WriteError(w, http.StatusNotFound, "not_found", "footer link not found") - return - } - code, msg := mapLinkError(err) - WriteError(w, code, "validation_error", msg) + writeItemError(w, err, "footer link not found", mapLinkError) return } WriteJSON(w, http.StatusOK, footerLinkToResponse(link)) } func (h *PersonalizationHandler) HandleDeleteFooterLink(w http.ResponseWriter, r *http.Request) { - id := r.PathValue("id") - if id == "" { - WriteError(w, http.StatusBadRequest, "validation_error", "invalid id") + id, ok := pathID(w, r) + if !ok { return } if err := h.svc.DeleteFooterLink(r.Context(), id); err != nil { - if errors.Is(err, status.ErrNotFound) { - WriteError(w, http.StatusNotFound, "not_found", "footer link not found") - return - } - WriteError(w, http.StatusInternalServerError, "internal_error", err.Error()) + writeItemError(w, err, "footer link not found", nil) return } w.WriteHeader(http.StatusNoContent) @@ -299,8 +261,7 @@ func (h *PersonalizationHandler) HandleReorderFooterLinks(w http.ResponseWriter, var req struct { IDs []string `json:"ids"` } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - WriteError(w, http.StatusBadRequest, "validation_error", "invalid JSON") + if !decodeJSON(w, r, &req) { return } links, err := h.svc.ReorderFooterLinks(r.Context(), req.IDs) @@ -327,8 +288,7 @@ func (h *PersonalizationHandler) HandleCreateFAQItem(w http.ResponseWriter, r *h Question string `json:"question"` AnswerMD string `json:"answer_md"` } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - WriteError(w, http.StatusBadRequest, "validation_error", "invalid JSON") + if !decodeJSON(w, r, &req) { return } item, err := h.svc.CreateFAQItem(r.Context(), req.Question, req.AnswerMD) @@ -341,44 +301,32 @@ func (h *PersonalizationHandler) HandleCreateFAQItem(w http.ResponseWriter, r *h } func (h *PersonalizationHandler) HandleUpdateFAQItem(w http.ResponseWriter, r *http.Request) { - id := r.PathValue("id") - if id == "" { - WriteError(w, http.StatusBadRequest, "validation_error", "invalid id") + id, ok := pathID(w, r) + if !ok { return } var req struct { Question string `json:"question"` AnswerMD string `json:"answer_md"` } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - WriteError(w, http.StatusBadRequest, "validation_error", "invalid JSON") + if !decodeJSON(w, r, &req) { return } item, err := h.svc.UpdateFAQItem(r.Context(), id, req.Question, req.AnswerMD) if err != nil { - if errors.Is(err, status.ErrNotFound) { - WriteError(w, http.StatusNotFound, "not_found", "FAQ item not found") - return - } - code, msg := mapFAQError(err) - WriteError(w, code, "validation_error", msg) + writeItemError(w, err, "FAQ item not found", mapFAQError) return } WriteJSON(w, http.StatusOK, faqItemToResponse(item)) } func (h *PersonalizationHandler) HandleDeleteFAQItem(w http.ResponseWriter, r *http.Request) { - id := r.PathValue("id") - if id == "" { - WriteError(w, http.StatusBadRequest, "validation_error", "invalid id") + id, ok := pathID(w, r) + if !ok { return } if err := h.svc.DeleteFAQItem(r.Context(), id); err != nil { - if errors.Is(err, status.ErrNotFound) { - WriteError(w, http.StatusNotFound, "not_found", "FAQ item not found") - return - } - WriteError(w, http.StatusInternalServerError, "internal_error", err.Error()) + writeItemError(w, err, "FAQ item not found", nil) return } w.WriteHeader(http.StatusNoContent) @@ -388,8 +336,7 @@ func (h *PersonalizationHandler) HandleReorderFAQ(w http.ResponseWriter, r *http var req struct { IDs []string `json:"ids"` } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - WriteError(w, http.StatusBadRequest, "validation_error", "invalid JSON") + if !decodeJSON(w, r, &req) { return } items, err := h.svc.ReorderFAQItems(r.Context(), req.IDs) @@ -400,6 +347,38 @@ func (h *PersonalizationHandler) HandleReorderFAQ(w http.ResponseWriter, r *http WriteJSON(w, http.StatusOK, map[string]interface{}{"items": faqItemsToResponse(items)}) } +// --- Request helpers --- + +func decodeJSON(w http.ResponseWriter, r *http.Request, v any) bool { + if err := json.NewDecoder(r.Body).Decode(v); err != nil { + WriteError(w, http.StatusBadRequest, "validation_error", "invalid JSON") + return false + } + return true +} + +func pathID(w http.ResponseWriter, r *http.Request) (string, bool) { + id := r.PathValue("id") + if id == "" { + WriteError(w, http.StatusBadRequest, "validation_error", "invalid id") + return "", false + } + return id, true +} + +// writeItemError answers 404 for a missing item, then the validation error mapErr gives, or a 500 when mapErr is nil. +func writeItemError(w http.ResponseWriter, err error, notFound string, mapErr func(error) (int, string)) { + switch { + case errors.Is(err, status.ErrNotFound): + WriteError(w, http.StatusNotFound, "not_found", notFound) + case mapErr != nil: + code, msg := mapErr(err) + WriteError(w, code, "validation_error", msg) + default: + WriteError(w, http.StatusInternalServerError, "internal_error", err.Error()) + } +} + // --- Response helpers --- type settingsColorsResp struct { diff --git a/internal/app/app.go b/internal/app/app.go index 4e9342a0..34217b5d 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -173,20 +173,7 @@ func New(cfg Config, logger *slog.Logger, opts ...Option) (*App, error) { if err != nil { return nil, err } - clientIP := ratelimit.NewClientIPResolver(trustedProxies) - - // --- Rate limiters --- - // Public surfaces (/status/, /mcp, /oauth/) take the tight bucket. - a.rl = ratelimit.New(10, 20, clientIP) - // Pings: unknown tokens per address, known tokens per token whatever the address. - a.pingRL = ratelimit.New(10, 20, clientIP) - a.pingTokenRL = ratelimit.New(1, 60, nil) - // /api/ gets its own, far looser one: a dashboard load fans out dozens of - // parallel calls, so the tight bucket would 429 ordinary use. This is a - // flood ceiling, not a quota — it must never be reachable by the UI. - a.apiRL = ratelimit.New(50, 200, clientIP) - // Status-page subscriptions: five per hour and per address. - a.subscribeRL = ratelimit.New(5.0/3600.0, 5, clientIP) + a.wireRateLimiters(ratelimit.NewClientIPResolver(trustedProxies)) if cfg.K8sNamespaces != "" { logger.Info("K8s namespace allowlist configured", "namespaces", cfg.K8sNamespaces) @@ -226,8 +213,7 @@ func New(cfg Config, logger *slog.Logger, opts ...Option) (*App, error) { a.hbStore = hbStore certStore := store.NewCertificateStore(db) a.certStore = certStore - resStore := store.NewResourceStore(db) - a.resStore = resStore + a.resStore = store.NewResourceStore(db) alertStore := store.NewAlertStore(db) a.alertStore = alertStore channelStore := store.NewChannelStore(db) @@ -242,44 +228,19 @@ func New(cfg Config, logger *slog.Logger, opts ...Option) (*App, error) { webhookStore := store.NewWebhookStore(db) updateStore := store.NewUpdateStore(db) a.updateStore = updateStore - agentStore := store.NewAgentStore(db) - a.agentStore = agentStore + a.agentStore = store.NewAgentStore(db) // The mode gate lives in Start(), after the license manager has resolved the // edition. Evaluating it here would read the package default and reject every // edition, Pro included. - // --- License manager --- - lm, err := extension.NewEditionSource(extension.SourceConfig{ - LicenseKey: cfg.LicenseKey, - PublicKeyB64: cfg.PublicKeyB64, - DataDir: filepath.Dir(cfg.DBPath), - Version: cfg.Version, - BuildDate: cfg.BuildDate, - Logger: logger, - }) - if err != nil { - logger.Warn("license manager initialization failed, running as Community Edition", "error", err) - } else if lm != nil { - a.licenseMgr = lm - extension.CurrentEdition = lm.Edition - } + a.wireLicense() - // --- Runtime detection --- - rt, err := runtime.Detect(ctx, logger) - if err != nil { + if err := a.wireRuntime(ctx); err != nil { _ = db.Close() - return nil, fmt.Errorf("detect container runtime: %w", err) - } - a.rt = rt - if dr, ok := rt.(*docker.Runtime); ok { - dr.SetProxyLabels(cfg.ProxyLabels) - } - - if err := rt.TryConnect(ctx); err != nil { - logger.Warn("container runtime unavailable, starting in degraded mode", "runtime", rt.Name(), "error", err) - rt.SetDisconnected() + return nil, err } + rt := a.rt // Swarm topology stores + ingest are created unconditionally: a remote agent // may report a swarm even when this server's own runtime is docker or @@ -296,64 +257,12 @@ func New(cfg Config, logger *slog.Logger, opts ...Option) (*App, error) { a.k8sIngest = kubernetes.NewIngestService(a.k8sStore, logger) a.k8sAlerts = kubernetes.NewK8sAlertChecker(logger) - // --- Swarm detection: armed for any Docker runtime, run now if it answers --- - if dr, ok := rt.(*docker.Runtime); ok { - a.swarmDetector = swarm.NewDetector(dr.Client(), logger) - a.swarmRecheckNow = make(chan struct{}, 1) - if rt.IsConnected() { - result, err := a.swarmDetector.Detect(ctx) - if err != nil { - logger.Warn("Swarm detection failed, continuing without Swarm support", "error", err) - } else if cluster := result.Cluster(); cluster != nil { - a.swarmCluster.Store(cluster) - a.swarmMgr.Store(newSwarmManager(dr, a.swarmNodeStore, logger)) - } - } - } + a.wireSwarmDetection(ctx) - // --- Services --- - var logFetcher container.LogFetcher - if lf, ok := rt.(container.LogFetcher); ok { - logFetcher = lf - } - a.containerSvc = container.NewService(container.Deps{ - Store: containerStore, - Logger: logger, - LogFetcher: logFetcher, - RestartChecker: alert.NewRestartDetector(containerStore, logger), - Discoverer: rt, - AgentRuntime: agentRuntimeResolver{store: agentStore}, - }) + a.wireCoreServices() uptimeCalc := container.NewUptimeCalculator(containerStore) - a.securitySvc = security.NewService(security.Deps{Logger: logger}) - a.resourceSvc = resource.NewService(resource.Deps{ - Store: resStore, - Runtime: rt, - ContainerSvc: a.containerSvc, - Logger: logger, - RawWindow: cfg.Retention.Snapshots, - }) - a.certSvc = certificate.NewService(certificate.Deps{ - Store: certStore, - Logger: logger, - }) - - // --- Endpoint monitoring --- - a.checkEngine = endpoint.NewCheckEngine(func(endpointID string, result endpoint.CheckResult) { - a.endpointSvc.ProcessCheckResult(ctx, endpointID, result) - if len(result.TLSPeerCertificates) > 0 { - ep, err := a.endpointSvc.GetEndpoint(ctx, endpointID) - if err == nil && ep != nil && certificate.IsHTTPS(ep.Target) { - a.certSvc.ProcessAutoDetectedCerts(ctx, endpointID, ep.Target, result.TLSPeerCertificates, result.TLSOCSPResponse) - } - } - }, logger) - a.endpointSvc = endpoint.NewService(endpoint.Deps{ - Store: epStore, - Engine: a.checkEngine, - Logger: logger, - }) + a.wireEndpointMonitoring(ctx) alertDetector := alert.NewEndpointAlertDetector() // --- Heartbeat monitoring --- @@ -378,20 +287,7 @@ func New(cfg Config, logger *slog.Logger, opts ...Option) (*App, error) { AllowPlaintext: cfg.SMTP.AllowPlaintext, } - if cfg.SMTP.AllowPlaintext { - logger.Warn("SMTP plaintext allowed: a server without STARTTLS receives mail in clear", "variable", "MAINTENANT_SMTP_ALLOW_PLAINTEXT") - } - a.notifier = alert.NewNotifier(channelStore, logger, cfg.AllowPrivateWebhooks, alert.WithBaseURL(cfg.BaseURL)) - if a.ext.Channels != nil { - channels := a.ext.Channels(extpoint.ChannelDeps{ - HTTPClient: a.notifier.HTTPClient(), - SMTP: smtpCfg, - Logger: logger, - }) - for chType, sender := range channels { - a.notifier.RegisterChannel(chType, sender) - } - } + a.wireNotifier(channelStore, smtpCfg) // --- SSE brokers --- a.broker = v1.NewSSEBroker(logger) @@ -405,52 +301,12 @@ func New(cfg Config, logger *slog.Logger, opts ...Option) (*App, error) { a.swarmIngest.SetBroadcaster(topologyBroadcast) a.k8sIngest.SetBroadcaster(topologyBroadcast) - // --- Host operating system end of support --- - var eolFetcher *eol.Fetcher - if !cfg.DisableOSEOLRefresh { - eolFetcher = &eol.Fetcher{ - Client: &http.Client{Timeout: 15 * time.Second, Transport: trust.HTTPTransport()}, - UserAgent: "maintenant/" + cfg.Version + " (+https://maintenant.dev)", - } - } - a.eolSvc, err = eol.New(eol.Deps{ - Store: a.agentStore, - Fetcher: eolFetcher, - Emit: a.emitAlert, - OnChanged: a.broadcastAgentUpdated, - Logger: logger.With("component", "os-eol"), - }) - if err != nil { - return nil, fmt.Errorf("os end-of-support service: %w", err) - } - - // Agent session registry and gRPC server (served at Start time where multi-host is open). - if a.ext.MultiHost != nil { - mh := a.ext.MultiHost(extpoint.MultiHostDeps{ - AgentStore: a.agentStore, - Broadcaster: &sseBroadcaster{broker: a.broker}, - RateLimitPerSecond: cfg.MultiHost.AgentRateLimitPerSecond, - DemoMode: cfg.DemoMode, - Container: a.containerSvc, - Resource: a.resourceSvc, - Endpoint: a.endpointSvc, - Certificate: a.certSvc, - Heartbeat: a.heartbeatSvc, - Swarm: a.swarmIngest, - Kubernetes: a.k8sIngest, - HostOS: a.eolSvc, - // Provision endpoint/cert monitors from a remote container's labels - // (the agent probes them itself; the server never dials them). - LabelSync: func(ctx context.Context, agentID, containerName, externalID string, labels map[string]string) { - a.endpointSvc.SyncAgentEndpoints(ctx, agentID, containerName, externalID, labels) - a.certSvc.SyncAgentCerts(ctx, agentID, externalID, labels) - }, - Logger: logger, - }) - a.agentSessions = mh.Sessions - a.serveAgents = mh.Serve + if err := a.wireOSEOL(); err != nil { + return nil, err } + a.wireMultiHost() + a.alertEngine = alert.NewEngine(alert.EngineDeps{ AlertStore: alertStore, ChannelStore: channelStore, @@ -470,118 +326,17 @@ func New(cfg Config, logger *slog.Logger, opts ...Option) (*App, error) { logger.With("component", "container-down")) } - // --- Public Status Page --- - a.statusSvc = status.NewService(status.Deps{ - Components: statusCompStore, - Logger: logger, - Incidents: incidentStore, - Maintenance: maintenanceStore, - PublicBroadcaster: func(eventType string, data any) { - a.statusBroker.Broadcast(v1.SSEEvent{Type: eventType, Data: data}) - }, - AdminBroadcaster: func(eventType string, data any) { - a.broker.Broadcast(v1.SSEEvent{Type: eventType, Data: data}) - }, - }) - a.wireStatusProvider() - if a.ext.StatusPage != nil { - sp := a.ext.StatusPage(extpoint.StatusPageDeps{ - Service: a.statusSvc, - Components: statusCompStore, - Incidents: incidentStore, - Maintenance: maintenanceStore, - Subscribers: subscriberStore, - Personalization: personalizationStore, - SMTP: smtpCfg, - BaseURL: cfg.BaseURL, - Logger: logger, - }) - a.statusSvc.SetIncidentHandler(sp.Incidents) - a.statusSvc.SetSubscriberNotifier(sp.Notifier) - a.maintScheduler = sp.Maintenance - a.personalizationSvc = sp.Personalization - a.statusMailer = sp.Mailer - } - a.subscriberSvc = status.NewSubscriberService(subscriberStore, a.statusMailer, cfg.BaseURL, logger) - a.statusSvc.SetSubscriberService(a.subscriberSvc) - personalizationPublicHandler := status.NewPersonalizationPublicHandler(personalizationStore, logger) - a.statusHandler = status.NewHandler(a.statusSvc, a.statusBroker, logger, a.subscribeRL, status.PageURL(cfg.StatusURL, cfg.BaseURL)) - a.statusHandler.SetPersonalizationHandler(personalizationPublicHandler) + a.wireStatusPage(incidentStore, maintenanceStore, subscriberStore, personalizationStore, smtpCfg) // --- Webhook dispatcher --- a.webhookDispatcher = webhook.NewDispatcher(webhookStore, a.notifier, logger) - // --- Update intelligence --- - registryClient := update.NewRegistryClient() - updateScanner := update.NewScanner(registryClient, updateStore, logger) - containerAdapter := update.NewContainerServiceAdapter(a.containerSvc, logger) - if fetcher := updateDetailsFetcher(a.rt, logger); fetcher != nil { - containerAdapter.WithDetailsFetcher(fetcher) - } + containerAdapter := a.wireUpdates() - var updateEnricher update.Enricher - if a.ext.Enricher != nil { - updateEnricher = a.ext.Enricher(extpoint.EnricherDeps{ - Store: updateStore, - Registry: registryClient, - Insights: a.securitySvc, - Restarts: containerStore, - Logger: logger, - }) - } - a.updateSvc = update.NewService(update.Deps{ - Store: updateStore, - Scanner: updateScanner, - Containers: containerAdapter, - Logger: logger, - Enricher: updateEnricher, - }) - - // --- Security posture scoring --- ackStore := store.NewAcknowledgmentStore(db) - if a.ext.PostureScorer != nil { - a.scorer = a.ext.PostureScorer(extpoint.PostureDeps{ - Certs: &CertPostureAdapter{CertSvc: a.certSvc}, - CVEs: &CVEPostureAdapter{Store: updateStore}, - CVEEvaluations: &CVEEvaluationPostureAdapter{Store: updateStore}, - Updates: &UpdatePostureAdapter{Store: updateStore}, - Insights: a.securitySvc, - Acks: ackStore, - Threshold: cfg.SecurityScoreThreshold, - }) - } + a.wirePostureScorer(ackStore) - if cfg.SecurityScoreThreshold > 0 { - logger.Info("security posture threshold configured", "threshold", cfg.SecurityScoreThreshold) - } - - // --- Escalation policies --- - a.escalationStore = store.NewEscalationStore(db) - - var suppressor alert.MaintenanceSuppressor - if a.ext.Suppressor != nil { - suppressor = a.ext.Suppressor(extpoint.SuppressorDeps{Windows: maintenanceStore, Logger: logger}) - } - // Must be called before alertEngine.Start (invoked in App.Start). - if suppressor != nil { - a.alertEngine.SetMaintenanceSuppressor(suppressor) - } - - // SetEscalator must run before alertEngine.Start (called later in App.Start). - if a.ext.Escalation != nil { - esc := a.ext.Escalation(extpoint.EscalationDeps{ - Store: a.escalationStore, - Alerts: alertStore, - Channels: channelStore, - Notifier: a.notifier, - Suppressor: suppressor, - Logger: logger, - }) - a.escalationSvc = esc.Service - if esc.Escalator != nil { - a.alertEngine.SetEscalator(esc.Escalator) - } - } + a.wireEscalation(maintenanceStore, channelStore) // --- Wire alert callbacks --- a.wireAlertCallbacks(alertDetector) @@ -744,6 +499,310 @@ func New(cfg Config, logger *slog.Logger, opts ...Option) (*App, error) { return a, nil } +func (a *App) wireRateLimiters(clientIP *ratelimit.ClientIPResolver) { + // Public surfaces (/status/, /mcp, /oauth/) take the tight bucket. + a.rl = ratelimit.New(10, 20, clientIP) + // Pings: unknown tokens per address, known tokens per token whatever the address. + a.pingRL = ratelimit.New(10, 20, clientIP) + a.pingTokenRL = ratelimit.New(1, 60, nil) + // /api/ gets its own, far looser one: a dashboard load fans out dozens of + // parallel calls, so the tight bucket would 429 ordinary use. This is a + // flood ceiling, not a quota — it must never be reachable by the UI. + a.apiRL = ratelimit.New(50, 200, clientIP) + // Status-page subscriptions: five per hour and per address. + a.subscribeRL = ratelimit.New(5.0/3600.0, 5, clientIP) +} + +func (a *App) wireLicense() { + lm, err := extension.NewEditionSource(extension.SourceConfig{ + LicenseKey: a.cfg.LicenseKey, + PublicKeyB64: a.cfg.PublicKeyB64, + DataDir: filepath.Dir(a.cfg.DBPath), + Version: a.cfg.Version, + BuildDate: a.cfg.BuildDate, + Logger: a.logger, + }) + if err != nil { + a.logger.Warn("license manager initialization failed, running as Community Edition", "error", err) + } else if lm != nil { + a.licenseMgr = lm + extension.CurrentEdition = lm.Edition + } +} + +func (a *App) wireRuntime(ctx context.Context) error { + rt, err := runtime.Detect(ctx, a.logger) + if err != nil { + return fmt.Errorf("detect container runtime: %w", err) + } + a.rt = rt + if dr, ok := rt.(*docker.Runtime); ok { + dr.SetProxyLabels(a.cfg.ProxyLabels) + } + + if err := rt.TryConnect(ctx); err != nil { + a.logger.Warn("container runtime unavailable, starting in degraded mode", "runtime", rt.Name(), "error", err) + rt.SetDisconnected() + } + return nil +} + +// wireSwarmDetection arms Swarm detection for any Docker runtime and runs it now if the runtime answers. +func (a *App) wireSwarmDetection(ctx context.Context) { + dr, ok := a.rt.(*docker.Runtime) + if !ok { + return + } + a.swarmDetector = swarm.NewDetector(dr.Client(), a.logger) + a.swarmRecheckNow = make(chan struct{}, 1) + if !a.rt.IsConnected() { + return + } + result, err := a.swarmDetector.Detect(ctx) + if err != nil { + a.logger.Warn("Swarm detection failed, continuing without Swarm support", "error", err) + } else if cluster := result.Cluster(); cluster != nil { + a.swarmCluster.Store(cluster) + a.swarmMgr.Store(newSwarmManager(dr, a.swarmNodeStore, a.logger)) + } +} + +func (a *App) wireCoreServices() { + var logFetcher container.LogFetcher + if lf, ok := a.rt.(container.LogFetcher); ok { + logFetcher = lf + } + a.containerSvc = container.NewService(container.Deps{ + Store: a.containerStore, + Logger: a.logger, + LogFetcher: logFetcher, + RestartChecker: alert.NewRestartDetector(a.containerStore, a.logger), + Discoverer: a.rt, + AgentRuntime: agentRuntimeResolver{store: a.agentStore}, + }) + + a.securitySvc = security.NewService(security.Deps{Logger: a.logger}) + a.resourceSvc = resource.NewService(resource.Deps{ + Store: a.resStore, + Runtime: a.rt, + ContainerSvc: a.containerSvc, + Logger: a.logger, + RawWindow: a.cfg.Retention.Snapshots, + }) + a.certSvc = certificate.NewService(certificate.Deps{ + Store: a.certStore, + Logger: a.logger, + }) +} + +func (a *App) wireEndpointMonitoring(ctx context.Context) { + a.checkEngine = endpoint.NewCheckEngine(func(endpointID string, result endpoint.CheckResult) { + a.endpointSvc.ProcessCheckResult(ctx, endpointID, result) + if len(result.TLSPeerCertificates) > 0 { + ep, err := a.endpointSvc.GetEndpoint(ctx, endpointID) + if err == nil && ep != nil && certificate.IsHTTPS(ep.Target) { + a.certSvc.ProcessAutoDetectedCerts(ctx, endpointID, ep.Target, result.TLSPeerCertificates, result.TLSOCSPResponse) + } + } + }, a.logger) + a.endpointSvc = endpoint.NewService(endpoint.Deps{ + Store: a.epStore, + Engine: a.checkEngine, + Logger: a.logger, + }) +} + +func (a *App) wireNotifier(channelStore *store.ChannelStoreImpl, smtpCfg extpoint.SMTPConfig) { + if a.cfg.SMTP.AllowPlaintext { + a.logger.Warn("SMTP plaintext allowed: a server without STARTTLS receives mail in clear", "variable", "MAINTENANT_SMTP_ALLOW_PLAINTEXT") + } + a.notifier = alert.NewNotifier(channelStore, a.logger, a.cfg.AllowPrivateWebhooks, alert.WithBaseURL(a.cfg.BaseURL)) + if a.ext.Channels != nil { + channels := a.ext.Channels(extpoint.ChannelDeps{ + HTTPClient: a.notifier.HTTPClient(), + SMTP: smtpCfg, + Logger: a.logger, + }) + for chType, sender := range channels { + a.notifier.RegisterChannel(chType, sender) + } + } +} + +// wireOSEOL builds the host operating system end-of-support service. +func (a *App) wireOSEOL() error { + var eolFetcher *eol.Fetcher + if !a.cfg.DisableOSEOLRefresh { + eolFetcher = &eol.Fetcher{ + Client: &http.Client{Timeout: 15 * time.Second, Transport: trust.HTTPTransport()}, + UserAgent: "maintenant/" + a.cfg.Version + " (+https://maintenant.dev)", + } + } + var err error + a.eolSvc, err = eol.New(eol.Deps{ + Store: a.agentStore, + Fetcher: eolFetcher, + Emit: a.emitAlert, + OnChanged: a.broadcastAgentUpdated, + Logger: a.logger.With("component", "os-eol"), + }) + if err != nil { + return fmt.Errorf("os end-of-support service: %w", err) + } + return nil +} + +// wireMultiHost builds the agent session registry and gRPC server, served at Start time where multi-host is open. +func (a *App) wireMultiHost() { + if a.ext.MultiHost == nil { + return + } + mh := a.ext.MultiHost(extpoint.MultiHostDeps{ + AgentStore: a.agentStore, + Broadcaster: &sseBroadcaster{broker: a.broker}, + RateLimitPerSecond: a.cfg.MultiHost.AgentRateLimitPerSecond, + DemoMode: a.cfg.DemoMode, + Container: a.containerSvc, + Resource: a.resourceSvc, + Endpoint: a.endpointSvc, + Certificate: a.certSvc, + Heartbeat: a.heartbeatSvc, + Swarm: a.swarmIngest, + Kubernetes: a.k8sIngest, + HostOS: a.eolSvc, + // Provision endpoint/cert monitors from a remote container's labels + // (the agent probes them itself; the server never dials them). + LabelSync: func(ctx context.Context, agentID, containerName, externalID string, labels map[string]string) { + a.endpointSvc.SyncAgentEndpoints(ctx, agentID, containerName, externalID, labels) + a.certSvc.SyncAgentCerts(ctx, agentID, externalID, labels) + }, + Logger: a.logger, + }) + a.agentSessions = mh.Sessions + a.serveAgents = mh.Serve +} + +func (a *App) wireStatusPage( + incidentStore *store.IncidentStoreImpl, + maintenanceStore *store.MaintenanceStoreImpl, + subscriberStore *store.SubscriberStoreImpl, + personalizationStore *store.PersonalizationStoreImpl, + smtpCfg extpoint.SMTPConfig, +) { + a.statusSvc = status.NewService(status.Deps{ + Components: a.statusCompStore, + Logger: a.logger, + Incidents: incidentStore, + Maintenance: maintenanceStore, + PublicBroadcaster: func(eventType string, data any) { + a.statusBroker.Broadcast(v1.SSEEvent{Type: eventType, Data: data}) + }, + AdminBroadcaster: func(eventType string, data any) { + a.broker.Broadcast(v1.SSEEvent{Type: eventType, Data: data}) + }, + }) + a.wireStatusProvider() + if a.ext.StatusPage != nil { + sp := a.ext.StatusPage(extpoint.StatusPageDeps{ + Service: a.statusSvc, + Components: a.statusCompStore, + Incidents: incidentStore, + Maintenance: maintenanceStore, + Subscribers: subscriberStore, + Personalization: personalizationStore, + SMTP: smtpCfg, + BaseURL: a.cfg.BaseURL, + Logger: a.logger, + }) + a.statusSvc.SetIncidentHandler(sp.Incidents) + a.statusSvc.SetSubscriberNotifier(sp.Notifier) + a.maintScheduler = sp.Maintenance + a.personalizationSvc = sp.Personalization + a.statusMailer = sp.Mailer + } + a.subscriberSvc = status.NewSubscriberService(subscriberStore, a.statusMailer, a.cfg.BaseURL, a.logger) + a.statusSvc.SetSubscriberService(a.subscriberSvc) + personalizationPublicHandler := status.NewPersonalizationPublicHandler(personalizationStore, a.logger) + a.statusHandler = status.NewHandler(a.statusSvc, a.statusBroker, a.logger, a.subscribeRL, status.PageURL(a.cfg.StatusURL, a.cfg.BaseURL)) + a.statusHandler.SetPersonalizationHandler(personalizationPublicHandler) +} + +// wireUpdates builds the update intelligence service and returns the container adapter the router also serves. +func (a *App) wireUpdates() *update.ContainerServiceAdapter { + registryClient := update.NewRegistryClient() + updateScanner := update.NewScanner(registryClient, a.updateStore, a.logger) + containerAdapter := update.NewContainerServiceAdapter(a.containerSvc, a.logger) + if fetcher := updateDetailsFetcher(a.rt, a.logger); fetcher != nil { + containerAdapter.WithDetailsFetcher(fetcher) + } + + var updateEnricher update.Enricher + if a.ext.Enricher != nil { + updateEnricher = a.ext.Enricher(extpoint.EnricherDeps{ + Store: a.updateStore, + Registry: registryClient, + Insights: a.securitySvc, + Restarts: a.containerStore, + Logger: a.logger, + }) + } + a.updateSvc = update.NewService(update.Deps{ + Store: a.updateStore, + Scanner: updateScanner, + Containers: containerAdapter, + Logger: a.logger, + Enricher: updateEnricher, + }) + return containerAdapter +} + +func (a *App) wirePostureScorer(ackStore *store.AcknowledgmentStoreImpl) { + if a.ext.PostureScorer != nil { + a.scorer = a.ext.PostureScorer(extpoint.PostureDeps{ + Certs: &CertPostureAdapter{CertSvc: a.certSvc}, + CVEs: &CVEPostureAdapter{Store: a.updateStore}, + CVEEvaluations: &CVEEvaluationPostureAdapter{Store: a.updateStore}, + Updates: &UpdatePostureAdapter{Store: a.updateStore}, + Insights: a.securitySvc, + Acks: ackStore, + Threshold: a.cfg.SecurityScoreThreshold, + }) + } + + if a.cfg.SecurityScoreThreshold > 0 { + a.logger.Info("security posture threshold configured", "threshold", a.cfg.SecurityScoreThreshold) + } +} + +func (a *App) wireEscalation(maintenanceStore *store.MaintenanceStoreImpl, channelStore *store.ChannelStoreImpl) { + a.escalationStore = store.NewEscalationStore(a.db) + + var suppressor alert.MaintenanceSuppressor + if a.ext.Suppressor != nil { + suppressor = a.ext.Suppressor(extpoint.SuppressorDeps{Windows: maintenanceStore, Logger: a.logger}) + } + // Must be called before alertEngine.Start (invoked in App.Start). + if suppressor != nil { + a.alertEngine.SetMaintenanceSuppressor(suppressor) + } + + // SetEscalator must run before alertEngine.Start (called later in App.Start). + if a.ext.Escalation != nil { + esc := a.ext.Escalation(extpoint.EscalationDeps{ + Store: a.escalationStore, + Alerts: a.alertStore, + Channels: channelStore, + Notifier: a.notifier, + Suppressor: suppressor, + Logger: a.logger, + }) + a.escalationSvc = esc.Service + if esc.Escalator != nil { + a.alertEngine.SetEscalator(esc.Escalator) + } + } +} + // multihostPlanAllowed reports whether the multi-host plan may run. // // It is not the same question as extension.Allows(CapMultihost). A Personal @@ -835,16 +894,8 @@ func (a *App) Start(ctx context.Context) error { a.licenseMgr.Start(ctx) } - // Mode gate: server mode needs multi-host. Checked here because - // licenseMgr.Start runs the initial verification synchronously, so the - // edition is settled — NewManager has already loaded the disk cache and Start - // has refreshed it. Checking it in New() read the package default and - // rejected every edition, Pro included. - if a.cfg.Mode != "" && a.cfg.Mode != "embedded" { - if !a.multihostPlanAllowed() { - return fmt.Errorf("%s mode requires the %s edition (current edition: %s)", - a.cfg.Mode, extension.MinEdition(extension.CapMultihost), extension.CurrentEdition()) - } + if err := a.enforceModeGate(); err != nil { + return err } a.alertEngine.Start(ctx) @@ -862,63 +913,10 @@ func (a *App) Start(ctx context.Context) error { // contained inside the package (FR-009/FR-011/FR-012). a.telemetrySvc.Start(ctx) - // Webhook observer - webhookObserverCh := make(chan v1.SSEEvent, 64) - a.broker.AddObserver(webhookObserverCh) - go func() { - for { - select { - case <-ctx.Done(): - return - case evt, ok := <-webhookObserverCh: - if !ok { - return - } - a.webhookDispatcher.HandleEvent(ctx, evt.Type, evt.Data) - } - } - }() - - // Background services (always run, regardless of runtime availability) - go a.rl.Start(ctx) - go a.apiRL.Start(ctx) - go a.subscribeRL.Start(ctx) - go a.pingRL.Start(ctx) - go a.pingTokenRL.Start(ctx) - go a.resourceSvc.Start(ctx) - go a.certSvc.Start(ctx) - if a.maintScheduler != nil { - go a.maintScheduler.Start(ctx) - } - go a.subscriberSvc.Start(ctx) - go a.updateSvc.Start(ctx) - - // Agent session ring-buffer tick + stale watcher. - if a.agentSessions != nil { - threshold := time.Duration(a.cfg.MultiHost.AgentStaleThresholdSeconds) * time.Second - if threshold == 0 { - threshold = 60 * time.Second - } - a.agentSessions.StartWatchers(ctx, threshold, a.agentStore.StaleAgents) - } - - // Enrollment token GC: purge unconsumed tokens older than 7 days, every hour. - if a.agentStore != nil { - go func() { - ticker := time.NewTicker(time.Hour) - defer ticker.Stop() - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - if err := a.agentStore.GcExpiredTokens(ctx); err != nil { - a.logger.Error("enrollment token GC failed", "err", err) - } - } - } - }() - } + a.startWebhookObserver(ctx) + a.startBackgroundServices(ctx) + a.startAgentWatchers(ctx) + a.startEnrollmentTokenGC(ctx) // Swarm manager loops; the Kubernetes reconcile is wired with each runtime // connection cycle. @@ -953,6 +951,104 @@ func (a *App) Start(ctx context.Context) error { // and manages reconnection in background when degraded (Phase 5). a.startRuntimeSupervisor(ctx) + if err := a.startAgentServer(ctx); err != nil { + return err + } + + a.startMCPSocket(ctx) + a.startHTTPServer(httpLn) + + // Wait for shutdown + <-ctx.Done() + return a.Shutdown() +} + +// enforceModeGate refuses any mode other than embedded when the multi-host plan may not run. +func (a *App) enforceModeGate() error { + // Mode gate: server mode needs multi-host. Checked here because + // licenseMgr.Start runs the initial verification synchronously, so the + // edition is settled — NewManager has already loaded the disk cache and Start + // has refreshed it. Checking it in New() read the package default and + // rejected every edition, Pro included. + if a.cfg.Mode != "" && a.cfg.Mode != "embedded" { + if !a.multihostPlanAllowed() { + return fmt.Errorf("%s mode requires the %s edition (current edition: %s)", + a.cfg.Mode, extension.MinEdition(extension.CapMultihost), extension.CurrentEdition()) + } + } + return nil +} + +// startWebhookObserver forwards every SSE event to the webhook dispatcher. +func (a *App) startWebhookObserver(ctx context.Context) { + webhookObserverCh := make(chan v1.SSEEvent, 64) + a.broker.AddObserver(webhookObserverCh) + go func() { + for { + select { + case <-ctx.Done(): + return + case evt, ok := <-webhookObserverCh: + if !ok { + return + } + a.webhookDispatcher.HandleEvent(ctx, evt.Type, evt.Data) + } + } + }() +} + +// startBackgroundServices starts the loops that always run, regardless of runtime availability. +func (a *App) startBackgroundServices(ctx context.Context) { + go a.rl.Start(ctx) + go a.apiRL.Start(ctx) + go a.subscribeRL.Start(ctx) + go a.pingRL.Start(ctx) + go a.pingTokenRL.Start(ctx) + go a.resourceSvc.Start(ctx) + go a.certSvc.Start(ctx) + if a.maintScheduler != nil { + go a.maintScheduler.Start(ctx) + } + go a.subscriberSvc.Start(ctx) + go a.updateSvc.Start(ctx) +} + +// startAgentWatchers starts the agent session ring-buffer tick and stale watcher. +func (a *App) startAgentWatchers(ctx context.Context) { + if a.agentSessions == nil { + return + } + threshold := time.Duration(a.cfg.MultiHost.AgentStaleThresholdSeconds) * time.Second + if threshold == 0 { + threshold = 60 * time.Second + } + a.agentSessions.StartWatchers(ctx, threshold, a.agentStore.StaleAgents) +} + +// startEnrollmentTokenGC purges unconsumed enrollment tokens older than 7 days, every hour. +func (a *App) startEnrollmentTokenGC(ctx context.Context) { + if a.agentStore == nil { + return + } + go func() { + ticker := time.NewTicker(time.Hour) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := a.agentStore.GcExpiredTokens(ctx); err != nil { + a.logger.Error("enrollment token GC failed", "err", err) + } + } + } + }() +} + +// startAgentServer serves the agent gRPC server where multi-host is open, then schedules the embedded agent. +func (a *App) startAgentServer(ctx context.Context) error { // Agent gRPC server — server/embedded modes only, where multi-host is open. if a.serveAgents != nil && a.cfg.Mode != "agent" { if a.multihostPlanAllowed() { @@ -978,7 +1074,10 @@ func (a *App) Start(ctx context.Context) error { if a.serveAgents != nil && a.cfg.Mode == "server" && a.cfg.MultiHost.EmbeddedAgent && a.multihostPlanAllowed() && !a.cfg.DemoMode { a.startEmbeddedAgent(ctx) } + return nil +} +func (a *App) startMCPSocket(ctx context.Context) { if a.cfg.Mode != "agent" && (a.cfg.MCP.Enabled || a.cfg.MCP.Socket) { if err := mcp.ServeSocket(ctx, a.mcpServer, mcp.SocketPath(a.cfg.DBPath), a.logger); err != nil { a.logger.Warn("local MCP socket unavailable, --mcp-stdio cannot reach this instance", "error", err) @@ -986,7 +1085,9 @@ func (a *App) Start(ctx context.Context) error { } else if a.cfg.Mode != "agent" { a.logger.Info("local MCP socket not served", "enable", "MAINTENANT_MCP=true or MAINTENANT_MCP_SOCKET=true") } +} +func (a *App) startHTTPServer(httpLn net.Listener) { // HTTP server — never in agent mode: an agent only streams to the server and // must not expose the UI/API. (main.go already exits before app.Start in agent // mode; this is a defensive invariant.) @@ -1000,10 +1101,6 @@ func (a *App) Start(ctx context.Context) error { } }() } - - // Wait for shutdown - <-ctx.Done() - return a.Shutdown() } // Shutdown performs a graceful shutdown of all services. diff --git a/internal/telemetry/env_test.go b/internal/app/config_truthy_test.go similarity index 59% rename from internal/telemetry/env_test.go rename to internal/app/config_truthy_test.go index 6666dd43..3ed1ec87 100644 --- a/internal/telemetry/env_test.go +++ b/internal/app/config_truthy_test.go @@ -1,11 +1,11 @@ // Copyright 2026 Benjamin Touchard (Kolapsis) // SPDX-License-Identifier: Apache-2.0 -package telemetry +package app import "testing" -func TestParseDisableTelemetry_Truthy(t *testing.T) { +func TestParseTruthy_Truthy(t *testing.T) { truthy := []string{ "1", "t", "T", "true", "True", "TRUE", "y", "Y", "yes", "Yes", "YES", @@ -14,19 +14,19 @@ func TestParseDisableTelemetry_Truthy(t *testing.T) { } for _, in := range truthy { t.Run(in, func(t *testing.T) { - first := parseDisableTelemetry(in) - second := parseDisableTelemetry(in) + first := parseTruthy(in) + second := parseTruthy(in) if !first { - t.Fatalf("parseDisableTelemetry(%q) = false, want true", in) + t.Fatalf("parseTruthy(%q) = false, want true", in) } if first != second { - t.Fatalf("parseDisableTelemetry(%q) is non-idempotent (%v vs %v)", in, first, second) + t.Fatalf("parseTruthy(%q) is non-idempotent (%v vs %v)", in, first, second) } }) } } -func TestParseDisableTelemetry_Falsy(t *testing.T) { +func TestParseTruthy_Falsy(t *testing.T) { falsy := []string{ "", " ", "\t", "\n", "0", "f", "false", "False", "FALSE", @@ -37,8 +37,8 @@ func TestParseDisableTelemetry_Falsy(t *testing.T) { } for _, in := range falsy { t.Run(in, func(t *testing.T) { - if parseDisableTelemetry(in) { - t.Fatalf("parseDisableTelemetry(%q) = true, want false", in) + if parseTruthy(in) { + t.Fatalf("parseTruthy(%q) = true, want false", in) } }) } diff --git a/internal/app/status.go b/internal/app/status.go index 5b0e4fd4..aa27ec85 100644 --- a/internal/app/status.go +++ b/internal/app/status.go @@ -68,14 +68,6 @@ func CertificateStatus(cert *certificate.CertMonitor) string { } } -// WorstStatus returns the most severe status between two values. -func WorstStatus(a, b string) string { - if status.Severity(a) >= status.Severity(b) { - return a - } - return b -} - // wireStatusProvider sets up the monitor status provider for the status page. func (a *App) wireStatusProvider() { a.statusSvc.SetMonitorStatusProvider(func(ctx context.Context, monitorType string, monitorID string) string { diff --git a/internal/app/wiring.go b/internal/app/wiring.go index 10d79d7e..88e28438 100644 --- a/internal/app/wiring.go +++ b/internal/app/wiring.go @@ -90,19 +90,19 @@ func certificateRecoveryEvent(m map[string]any) alert.Event { // wireAlertCallbacks wires all service event callbacks for SSE broadcasting, // alert event forwarding, and status page integration. func (a *App) wireAlertCallbacks(alertDetector *alert.EndpointAlertDetector) { - ctx := context.Background() - alertCh := a.alertEngine.EventChannel() - - sendAlert := func(evt alert.Event) { - alertCh <- evt - a.statusSvc.HandleAlertEvent(ctx, evt) - } - if a.downDetector != nil { - a.downDetector.SetEmitter(sendAlert) - } + a.downDetector.SetEmitter(a.emitAlert) + } + a.wireContainerEvents() + a.wireEndpointEvents(alertDetector) + a.wireHeartbeatEvents() + a.wireCertificateEvents() + a.wireResourceEvents() + a.wireSecurityEvents() +} - // Container events +func (a *App) wireContainerEvents() { + ctx := context.Background() a.containerSvc.SetEventCallback(func(eventType string, data any) { a.broker.Broadcast(v1.SSEEvent{Type: eventType, Data: data}) @@ -115,40 +115,13 @@ func (a *App) wireAlertCallbacks(alertDetector *alert.EndpointAlertDetector) { switch eventType { case "container.restart_alert": if ra, ok := data.(*alert.RestartAlert); ok && ra != nil { - severity := alert.SeverityWarning - if ra.RestartCount >= ra.Threshold*alert.CriticalRestartMultiplier { - severity = alert.SeverityCritical - } - sendAlert(alert.Event{ - Source: alert.SourceContainer, - AlertType: restartLoopAlertType, - Severity: severity, - Message: fmt.Sprintf("Container %s exceeded restart threshold (%d/%d)", ra.ContainerName, ra.RestartCount, ra.Threshold), - EntityType: "container", - EntityID: ra.ContainerID, - EntityName: ra.ContainerName, - AgentID: ra.AgentID, - Details: map[string]any{ - "restart_count": ra.RestartCount, - "threshold": ra.Threshold, - }, - Timestamp: ra.Timestamp, - }) + a.emitAlert(restartAlertEvent(ra)) } case "container.restart_recovery": if m, ok := data.(map[string]any); ok { - sendAlert(alert.Event{ - Source: alert.SourceContainer, - AlertType: restartLoopAlertType, - Severity: alert.SeverityInfo, - IsRecover: true, - Message: fmt.Sprintf("Container %s restart rate returned to normal", toString(m["container_name"])), - EntityType: "container", - EntityID: toString(m["container_id"]), - EntityName: toString(m["container_name"]), - AgentID: agentIDOf(m), - Timestamp: time.Now(), - }) + evt := restartRecoveryEvent(m) + evt.Timestamp = time.Now() + a.emitAlert(evt) } case "container.archived": if m, ok := data.(map[string]any); ok { @@ -159,42 +132,82 @@ func (a *App) wireAlertCallbacks(alertDetector *alert.EndpointAlertDetector) { } } case "container.health_changed": - m, ok := data.(map[string]any) - if !ok { - return - } - prev, _ := m["previous_health"].(*container.HealthStatus) - newH, _ := m["health_status"].(container.HealthStatus) - if prev != nil && *prev == container.HealthHealthy && newH == container.HealthUnhealthy { - sendAlert(alert.Event{ - Source: alert.SourceContainer, - AlertType: "health_unhealthy", - Severity: alert.SeverityWarning, - Message: "Container became unhealthy", - EntityType: "container", - EntityID: toString(m["id"]), - EntityName: toString(m["container_name"]), - AgentID: agentIDOf(m), - Details: m, - Timestamp: time.Now(), - }) - } else if prev != nil && *prev == container.HealthUnhealthy && newH == container.HealthHealthy { - sendAlert(alert.Event{ - Source: alert.SourceContainer, - AlertType: "health_unhealthy", - Severity: alert.SeverityInfo, - IsRecover: true, - Message: "Container recovered to healthy", - EntityType: "container", - EntityID: toString(m["id"]), - EntityName: toString(m["container_name"]), - AgentID: agentIDOf(m), - Details: m, - Timestamp: time.Now(), - }) + if m, ok := data.(map[string]any); ok { + if evt, raised := containerHealthEvent(m); raised { + evt.Timestamp = time.Now() + a.emitAlert(evt) + } } } }) +} + +func restartAlertEvent(ra *alert.RestartAlert) alert.Event { + severity := alert.SeverityWarning + if ra.RestartCount >= ra.Threshold*alert.CriticalRestartMultiplier { + severity = alert.SeverityCritical + } + return alert.Event{ + Source: alert.SourceContainer, + AlertType: restartLoopAlertType, + Severity: severity, + Message: fmt.Sprintf("Container %s exceeded restart threshold (%d/%d)", ra.ContainerName, ra.RestartCount, ra.Threshold), + EntityType: "container", + EntityID: ra.ContainerID, + EntityName: ra.ContainerName, + AgentID: ra.AgentID, + Details: map[string]any{ + "restart_count": ra.RestartCount, + "threshold": ra.Threshold, + }, + Timestamp: ra.Timestamp, + } +} + +// restartRecoveryEvent resolves a container's restart_loop alert. Caller sets Timestamp. +func restartRecoveryEvent(m map[string]any) alert.Event { + return alert.Event{ + Source: alert.SourceContainer, + AlertType: restartLoopAlertType, + Severity: alert.SeverityInfo, + IsRecover: true, + Message: fmt.Sprintf("Container %s restart rate returned to normal", toString(m["container_name"])), + EntityType: "container", + EntityID: toString(m["container_id"]), + EntityName: toString(m["container_name"]), + AgentID: agentIDOf(m), + } +} + +// containerHealthEvent builds the alert of a healthy/unhealthy transition, false for any other change. Caller sets Timestamp. +func containerHealthEvent(m map[string]any) (alert.Event, bool) { + prev, _ := m["previous_health"].(*container.HealthStatus) + newH, _ := m["health_status"].(container.HealthStatus) + evt := alert.Event{ + Source: alert.SourceContainer, + AlertType: "health_unhealthy", + EntityType: "container", + EntityID: toString(m["id"]), + EntityName: toString(m["container_name"]), + AgentID: agentIDOf(m), + Details: m, + } + switch { + case prev != nil && *prev == container.HealthHealthy && newH == container.HealthUnhealthy: + evt.Severity = alert.SeverityWarning + evt.Message = "Container became unhealthy" + case prev != nil && *prev == container.HealthUnhealthy && newH == container.HealthHealthy: + evt.Severity = alert.SeverityInfo + evt.IsRecover = true + evt.Message = "Container recovered to healthy" + default: + return evt, false + } + return evt, true +} + +func (a *App) wireEndpointEvents(alertDetector *alert.EndpointAlertDetector) { + ctx := context.Background() // Endpoint events: the alerts they announce are raised by the alert callback below. a.endpointSvc.SetEventCallback(func(eventType string, data any) { @@ -204,131 +217,140 @@ func (a *App) wireAlertCallbacks(alertDetector *alert.EndpointAlertDetector) { } }) - // Endpoint alerts a.endpointSvc.SetAlertCallback(func(ep *endpoint.Endpoint, result endpoint.CheckResult) (string, any) { - epName := ep.ContainerName - if epName == "" { - epName = ep.Name - } - - // Certificate trust is independent of the failure thresholds: a degraded - // host is answering, so it never trips them. Raise it as its own warning - // and clear it once the chain verifies. Both directions are idempotent — - // the engine dedups the alert and ignores a recovery with nothing active. - switch { - case result.Degraded: - sendAlert(alert.Event{ - Source: alert.SourceEndpoint, - AlertType: "certificate_untrusted", - Severity: alert.SeverityWarning, - Message: fmt.Sprintf("Endpoint %s is reachable but its certificate is not trusted: %s", ep.Target, result.DegradedReason), - EntityType: "endpoint", - EntityID: ep.ID, - EntityName: epName, - AgentID: ep.AgentID, - Details: map[string]any{ - "target": ep.Target, - "reason": result.DegradedReason, - }, - Timestamp: result.Timestamp, - }) - case result.Success: - sendAlert(alert.Event{ - Source: alert.SourceEndpoint, - AlertType: "certificate_untrusted", - Severity: alert.SeverityInfo, - IsRecover: true, - Message: fmt.Sprintf("Endpoint %s now presents a trusted certificate", ep.Target), - EntityType: "endpoint", - EntityID: ep.ID, - EntityName: epName, - AgentID: ep.AgentID, - Timestamp: result.Timestamp, - }) + if evt, raised := endpointTrustEvent(ep, result); raised { + a.emitAlert(evt) } al := alertDetector.EvaluateCheckResult(ep, result) if al == nil { return "", nil } - entityName := al.ContainerName - if entityName == "" { - entityName = ep.Name - } - if al.Type == "alert" { - sendAlert(alert.Event{ - Source: alert.SourceEndpoint, - AlertType: "consecutive_failure", - Severity: alert.SeverityCritical, - Message: fmt.Sprintf("Endpoint %s failed %d consecutive checks", al.Target, al.Failures), - EntityType: "endpoint", - EntityID: al.EndpointID, - EntityName: entityName, - AgentID: ep.AgentID, - Details: map[string]any{ - "target": al.Target, - "failures": al.Failures, - "threshold": al.Threshold, - "last_error": al.LastError, - }, - Timestamp: al.Timestamp, - }) - return "endpoint.alert", map[string]any{ - "endpoint_id": al.EndpointID, - "container_name": al.ContainerName, - "target": al.Target, - "consecutive_failures": al.Failures, - "threshold": al.Threshold, - "last_error": al.LastError, - "timestamp": al.Timestamp, - } + evt, sseType, sseData := endpointThresholdAlert(ep, al) + a.emitAlert(evt) + return sseType, sseData + }) + + // Endpoint removal → alert, certificate monitor and status component cleanup. + a.endpointSvc.SetEndpointRemovedCallback(func(callCtx context.Context, endpointID string) { + a.alertEngine.ResolveByEntity(callCtx, "endpoint", endpointID) + a.certSvc.DeleteByEndpointID(callCtx, endpointID) + if err := a.statusCompStore.RemoveDanglingMonitorRefs(callCtx, "endpoint", endpointID); err != nil { + a.logger.Error("failed to remove dangling endpoint refs from status components", "endpoint_id", endpointID, "error", err) } - sendAlert(alert.Event{ + }) +} + +// endpointTrustEvent raises or clears the certificate_untrusted alert of a check, false when the check does neither. +func endpointTrustEvent(ep *endpoint.Endpoint, result endpoint.CheckResult) (alert.Event, bool) { + epName := ep.ContainerName + if epName == "" { + epName = ep.Name + } + + // Certificate trust is independent of the failure thresholds: a degraded + // host is answering, so it never trips them. Raise it as its own warning + // and clear it once the chain verifies. Both directions are idempotent — + // the engine dedups the alert and ignores a recovery with nothing active. + evt := alert.Event{ + Source: alert.SourceEndpoint, + AlertType: "certificate_untrusted", + EntityType: "endpoint", + EntityID: ep.ID, + EntityName: epName, + AgentID: ep.AgentID, + Timestamp: result.Timestamp, + } + switch { + case result.Degraded: + evt.Severity = alert.SeverityWarning + evt.Message = fmt.Sprintf("Endpoint %s is reachable but its certificate is not trusted: %s", ep.Target, result.DegradedReason) + evt.Details = map[string]any{ + "target": ep.Target, + "reason": result.DegradedReason, + } + case result.Success: + evt.Severity = alert.SeverityInfo + evt.IsRecover = true + evt.Message = fmt.Sprintf("Endpoint %s now presents a trusted certificate", ep.Target) + default: + return evt, false + } + return evt, true +} + +// endpointThresholdAlert builds the consecutive_failure alert of a crossed threshold and the SSE event announcing it. +func endpointThresholdAlert(ep *endpoint.Endpoint, al *alert.EndpointAlert) (alert.Event, string, map[string]any) { + entityName := al.ContainerName + if entityName == "" { + entityName = ep.Name + } + if al.Type == "alert" { + evt := alert.Event{ Source: alert.SourceEndpoint, AlertType: "consecutive_failure", - Severity: alert.SeverityInfo, - IsRecover: true, - Message: fmt.Sprintf("Endpoint %s recovered after %d consecutive successes", al.Target, al.Successes), + Severity: alert.SeverityCritical, + Message: fmt.Sprintf("Endpoint %s failed %d consecutive checks", al.Target, al.Failures), EntityType: "endpoint", EntityID: al.EndpointID, EntityName: entityName, AgentID: ep.AgentID, Details: map[string]any{ - "target": al.Target, - "successes": al.Successes, - "threshold": al.Threshold, + "target": al.Target, + "failures": al.Failures, + "threshold": al.Threshold, + "last_error": al.LastError, }, Timestamp: al.Timestamp, - }) - return "endpoint.recovery", map[string]any{ - "endpoint_id": al.EndpointID, - "container_name": al.ContainerName, - "target": al.Target, - "consecutive_successes": al.Successes, - "threshold": al.Threshold, - "timestamp": al.Timestamp, } - }) - - // Endpoint removal → alert, certificate monitor and status component cleanup. - a.endpointSvc.SetEndpointRemovedCallback(func(callCtx context.Context, endpointID string) { - a.alertEngine.ResolveByEntity(callCtx, "endpoint", endpointID) - a.certSvc.DeleteByEndpointID(callCtx, endpointID) - if err := a.statusCompStore.RemoveDanglingMonitorRefs(callCtx, "endpoint", endpointID); err != nil { - a.logger.Error("failed to remove dangling endpoint refs from status components", "endpoint_id", endpointID, "error", err) + return evt, "endpoint.alert", map[string]any{ + "endpoint_id": al.EndpointID, + "container_name": al.ContainerName, + "target": al.Target, + "consecutive_failures": al.Failures, + "threshold": al.Threshold, + "last_error": al.LastError, + "timestamp": al.Timestamp, } - }) + } + evt := alert.Event{ + Source: alert.SourceEndpoint, + AlertType: "consecutive_failure", + Severity: alert.SeverityInfo, + IsRecover: true, + Message: fmt.Sprintf("Endpoint %s recovered after %d consecutive successes", al.Target, al.Successes), + EntityType: "endpoint", + EntityID: al.EndpointID, + EntityName: entityName, + AgentID: ep.AgentID, + Details: map[string]any{ + "target": al.Target, + "successes": al.Successes, + "threshold": al.Threshold, + }, + Timestamp: al.Timestamp, + } + return evt, "endpoint.recovery", map[string]any{ + "endpoint_id": al.EndpointID, + "container_name": al.ContainerName, + "target": al.Target, + "consecutive_successes": al.Successes, + "threshold": al.Threshold, + "timestamp": al.Timestamp, + } +} + +func (a *App) wireHeartbeatEvents() { + ctx := context.Background() - // Heartbeat alerts a.heartbeatSvc.SetAlertCallback(func(h *heartbeat.Heartbeat, alertType string, details map[string]any) { a.statusSvc.NotifyMonitorChanged(ctx, "heartbeat", h.ID) for _, evt := range heartbeatAlertEvents(h, alertType, details) { evt.Timestamp = time.Now() - sendAlert(evt) + a.emitAlert(evt) } }) - // Heartbeat events a.heartbeatSvc.SetEventCallback(func(eventType string, data any) { a.broker.Broadcast(v1.SSEEvent{Type: eventType, Data: data}) m, ok := data.(map[string]any) @@ -349,8 +371,10 @@ func (a *App) wireAlertCallbacks(alertDetector *alert.EndpointAlertDetector) { } } }) +} - // Certificate alerts +func (a *App) wireCertificateEvents() { + ctx := context.Background() a.certSvc.SetEventCallback(func(eventType string, data any) { a.broker.Broadcast(v1.SSEEvent{Type: eventType, Data: data}) m, ok := data.(map[string]any) @@ -370,33 +394,41 @@ func (a *App) wireAlertCallbacks(alertDetector *alert.EndpointAlertDetector) { } } + var evt alert.Event switch eventType { case "certificate.alert": - certAlertType, _ := m["alert_type"].(string) - severity := alert.SeverityCritical - if s, ok := m["severity"].(string); ok && s != "" { - severity = s - } - sendAlert(alert.Event{ - Source: alert.SourceCertificate, - AlertType: certAlertType, - Severity: severity, - Message: fmt.Sprintf("Certificate alert (%s) for %v:%v", certAlertType, m["hostname"], m["port"]), - EntityType: "certificate", - EntityID: toString(m["monitor_id"]), - EntityName: toString(m["hostname"]), - AgentID: agentIDOf(m), - Details: m, - Timestamp: time.Now(), - }) + evt = certificateAlertEvent(m) case "certificate.recovery": - evt := certificateRecoveryEvent(m) - evt.Timestamp = time.Now() - sendAlert(evt) + evt = certificateRecoveryEvent(m) + default: + return } + evt.Timestamp = time.Now() + a.emitAlert(evt) }) +} - // Resource alerts +// certificateAlertEvent raises the certificate alert the event names, critical unless it carries a severity. Caller sets Timestamp. +func certificateAlertEvent(m map[string]any) alert.Event { + certAlertType, _ := m["alert_type"].(string) + severity := alert.SeverityCritical + if s, ok := m["severity"].(string); ok && s != "" { + severity = s + } + return alert.Event{ + Source: alert.SourceCertificate, + AlertType: certAlertType, + Severity: severity, + Message: fmt.Sprintf("Certificate alert (%s) for %v:%v", certAlertType, m["hostname"], m["port"]), + EntityType: "certificate", + EntityID: toString(m["monitor_id"]), + EntityName: toString(m["hostname"]), + AgentID: agentIDOf(m), + Details: m, + } +} + +func (a *App) wireResourceEvents() { a.resourceSvc.SetEventCallback(func(eventType string, data any) { a.broker.Broadcast(v1.SSEEvent{Type: eventType, Data: data}) m, ok := data.(map[string]any) @@ -406,7 +438,7 @@ func (a *App) wireAlertCallbacks(alertDetector *alert.EndpointAlertDetector) { switch eventType { case "resource.alert": resAlertType, _ := m["alert_type"].(string) - sendAlert(alert.Event{ + a.emitAlert(alert.Event{ Source: alert.SourceResource, AlertType: resAlertType + "_threshold", Severity: alert.SeverityWarning, @@ -420,7 +452,7 @@ func (a *App) wireAlertCallbacks(alertDetector *alert.EndpointAlertDetector) { }) case "resource.recovery": recoveredType, _ := m["recovered_type"].(string) - sendAlert(alert.Event{ + a.emitAlert(alert.Event{ Source: alert.SourceResource, AlertType: recoveredType + "_threshold", Severity: alert.SeverityInfo, @@ -435,12 +467,14 @@ func (a *App) wireAlertCallbacks(alertDetector *alert.EndpointAlertDetector) { }) } }) +} - // Security insight alerts +func (a *App) wireSecurityEvents() { + ctx := context.Background() a.securitySvc.SetAlertCallback(func(containerID string, containerName string, insights []security.Insight, isRecover bool) { agentID := a.containerAgentID(ctx, containerID) if isRecover { - sendAlert(alert.Event{ + a.emitAlert(alert.Event{ Source: alert.SourceSecurity, AlertType: alert.AlertTypeDangerousConfig, Severity: alert.SeverityInfo, @@ -456,7 +490,7 @@ func (a *App) wireAlertCallbacks(alertDetector *alert.EndpointAlertDetector) { return } hs := security.HighestSeverity(insights) - sendAlert(alert.Event{ + a.emitAlert(alert.Event{ Source: alert.SourceSecurity, AlertType: alert.AlertTypeDangerousConfig, Severity: MapSecuritySeverity(hs), diff --git a/internal/commercial/multihost/dispatcher.go b/internal/commercial/multihost/dispatcher.go index 87b02fee..98ad9e2f 100644 --- a/internal/commercial/multihost/dispatcher.go +++ b/internal/commercial/multihost/dispatcher.go @@ -149,7 +149,7 @@ func eventMeta(evt *agentpb.AgentEvent, now time.Time) (EventMeta, error) { } // Dispatch routes evt to the handler matching its body type, attributing every -// event to agentID — the identity proven by the auth handshake, NOT the +// event to agentID, the identity proven by the auth handshake, NOT the // client-controlled agent_id carried on the wire. Returns an error if a handler // is wired and returns an error. Silently ignores events whose handler is nil. func (d *Dispatcher) Dispatch(ctx context.Context, agentID string, evt *agentpb.AgentEvent) error { @@ -166,78 +166,132 @@ func (d *Dispatcher) Dispatch(ctx context.Context, agentID string, evt *agentpb. return fmt.Errorf("dispatch: reject event from agent %q: %w", agentID, err) } + return d.route(ctx, agentID, evt, meta) +} + +func (d *Dispatcher) route(ctx context.Context, agentID string, evt *agentpb.AgentEvent, meta EventMeta) error { switch body := evt.GetBody().(type) { case *agentpb.AgentEvent_Container: - if d.deps.Container != nil { - if err := d.deps.Container.HandleAgentEvent(ctx, agentID, body.Container, meta); err != nil { - return fmt.Errorf("dispatch container event: %w", err) - } + return d.dispatchContainer(ctx, agentID, body.Container, meta) + case *agentpb.AgentEvent_Inventory: + return d.dispatchInventory(ctx, agentID, body.Inventory, meta) + case *agentpb.AgentEvent_Endpoint: + return d.dispatchEndpoint(ctx, agentID, body.Endpoint, meta) + case *agentpb.AgentEvent_Heartbeat: + return d.dispatchHeartbeat(ctx, agentID, body.Heartbeat) + case *agentpb.AgentEvent_Resource: + return d.dispatchResource(ctx, agentID, body.Resource, meta) + case *agentpb.AgentEvent_Certificate: + return d.dispatchCertificate(ctx, agentID, body.Certificate) + case *agentpb.AgentEvent_Swarm: + return d.dispatchSwarm(ctx, agentID, body.Swarm) + case *agentpb.AgentEvent_Kubernetes: + return d.dispatchKubernetes(ctx, agentID, body.Kubernetes) + case *agentpb.AgentEvent_HostOs: + return d.dispatchHostOS(ctx, agentID, body.HostOs) + case *agentpb.AgentEvent_Runtime: + return d.dispatchRuntime(ctx, agentID, body.Runtime) + } + return nil +} + +func (d *Dispatcher) dispatchEndpoint(ctx context.Context, agentID string, ev *agentpb.EndpointEvent, meta EventMeta) error { + if d.deps.Endpoint != nil { + if err := d.deps.Endpoint.HandleAgentEvent(ctx, agentID, ev, meta); err != nil { + return fmt.Errorf("dispatch endpoint event: %w", err) } - if d.deps.LabelSync != nil { - labels := body.Container.GetLabels() - if body.Container.GetDestroyed() { - labels = nil - } - d.deps.LabelSync(ctx, agentID, body.Container.GetName(), body.Container.GetContainerId(), labels) + } + return nil +} + +func (d *Dispatcher) dispatchHeartbeat(ctx context.Context, agentID string, ev *agentpb.HeartbeatEvent) error { + if d.deps.Heartbeat != nil { + if err := d.deps.Heartbeat.HandleAgentEvent(ctx, agentID, ev); err != nil { + return fmt.Errorf("dispatch heartbeat event: %w", err) } - case *agentpb.AgentEvent_Inventory: - if d.deps.Inventory != nil { - if err := d.deps.Inventory.HandleAgentInventory(ctx, agentID, body.Inventory, meta); err != nil { - return fmt.Errorf("dispatch container inventory: %w", err) - } + } + return nil +} + +func (d *Dispatcher) dispatchResource(ctx context.Context, agentID string, ev *agentpb.ResourceSample, meta EventMeta) error { + if d.deps.Resource != nil { + if err := d.deps.Resource.HandleAgentEvent(ctx, agentID, ev, meta); err != nil { + return fmt.Errorf("dispatch resource event: %w", err) } - if d.deps.LabelSync != nil { - for _, c := range body.Inventory.GetContainers() { - d.deps.LabelSync(ctx, agentID, c.GetName(), c.GetContainerId(), c.GetLabels()) - } + } + return nil +} + +func (d *Dispatcher) dispatchCertificate(ctx context.Context, agentID string, ev *agentpb.CertificateInfo) error { + if d.deps.Certificate != nil { + if err := d.deps.Certificate.HandleAgentEvent(ctx, agentID, ev); err != nil { + return fmt.Errorf("dispatch certificate event: %w", err) } - case *agentpb.AgentEvent_Endpoint: - if d.deps.Endpoint != nil { - if err := d.deps.Endpoint.HandleAgentEvent(ctx, agentID, body.Endpoint, meta); err != nil { - return fmt.Errorf("dispatch endpoint event: %w", err) - } + } + return nil +} + +func (d *Dispatcher) dispatchSwarm(ctx context.Context, agentID string, ev *agentpb.SwarmTopology) error { + if d.deps.Swarm != nil { + if err := d.deps.Swarm.HandleAgentEvent(ctx, agentID, ev); err != nil { + return fmt.Errorf("dispatch swarm topology: %w", err) } - case *agentpb.AgentEvent_Heartbeat: - if d.deps.Heartbeat != nil { - if err := d.deps.Heartbeat.HandleAgentEvent(ctx, agentID, body.Heartbeat); err != nil { - return fmt.Errorf("dispatch heartbeat event: %w", err) - } + } + return nil +} + +func (d *Dispatcher) dispatchKubernetes(ctx context.Context, agentID string, ev *agentpb.KubernetesTopology) error { + if d.deps.Kubernetes != nil { + if err := d.deps.Kubernetes.HandleAgentEvent(ctx, agentID, ev); err != nil { + return fmt.Errorf("dispatch kubernetes topology: %w", err) } - case *agentpb.AgentEvent_Resource: - if d.deps.Resource != nil { - if err := d.deps.Resource.HandleAgentEvent(ctx, agentID, body.Resource, meta); err != nil { - return fmt.Errorf("dispatch resource event: %w", err) - } + } + return nil +} + +func (d *Dispatcher) dispatchHostOS(ctx context.Context, agentID string, ev *agentpb.HostOSMsg) error { + if d.deps.HostOS != nil { + if err := d.deps.HostOS.HandleAgentHostOS(ctx, agentID, ev); err != nil { + return fmt.Errorf("dispatch host os identity: %w", err) } - case *agentpb.AgentEvent_Certificate: - if d.deps.Certificate != nil { - if err := d.deps.Certificate.HandleAgentEvent(ctx, agentID, body.Certificate); err != nil { - return fmt.Errorf("dispatch certificate event: %w", err) - } + } + return nil +} + +func (d *Dispatcher) dispatchRuntime(ctx context.Context, agentID string, ev *agentpb.RuntimeMsg) error { + if d.deps.Runtime != nil { + if err := d.deps.Runtime.HandleAgentRuntime(ctx, agentID, ev); err != nil { + return fmt.Errorf("dispatch runtime: %w", err) } - case *agentpb.AgentEvent_Swarm: - if d.deps.Swarm != nil { - if err := d.deps.Swarm.HandleAgentEvent(ctx, agentID, body.Swarm); err != nil { - return fmt.Errorf("dispatch swarm topology: %w", err) - } + } + return nil +} + +func (d *Dispatcher) dispatchContainer(ctx context.Context, agentID string, ev *agentpb.ContainerEvent, meta EventMeta) error { + if d.deps.Container != nil { + if err := d.deps.Container.HandleAgentEvent(ctx, agentID, ev, meta); err != nil { + return fmt.Errorf("dispatch container event: %w", err) } - case *agentpb.AgentEvent_Kubernetes: - if d.deps.Kubernetes != nil { - if err := d.deps.Kubernetes.HandleAgentEvent(ctx, agentID, body.Kubernetes); err != nil { - return fmt.Errorf("dispatch kubernetes topology: %w", err) - } + } + if d.deps.LabelSync != nil { + labels := ev.GetLabels() + if ev.GetDestroyed() { + labels = nil } - case *agentpb.AgentEvent_HostOs: - if d.deps.HostOS != nil { - if err := d.deps.HostOS.HandleAgentHostOS(ctx, agentID, body.HostOs); err != nil { - return fmt.Errorf("dispatch host os identity: %w", err) - } + d.deps.LabelSync(ctx, agentID, ev.GetName(), ev.GetContainerId(), labels) + } + return nil +} + +func (d *Dispatcher) dispatchInventory(ctx context.Context, agentID string, ev *agentpb.ContainerInventory, meta EventMeta) error { + if d.deps.Inventory != nil { + if err := d.deps.Inventory.HandleAgentInventory(ctx, agentID, ev, meta); err != nil { + return fmt.Errorf("dispatch container inventory: %w", err) } - case *agentpb.AgentEvent_Runtime: - if d.deps.Runtime != nil { - if err := d.deps.Runtime.HandleAgentRuntime(ctx, agentID, body.Runtime); err != nil { - return fmt.Errorf("dispatch runtime: %w", err) - } + } + if d.deps.LabelSync != nil { + for _, c := range ev.GetContainers() { + d.deps.LabelSync(ctx, agentID, c.GetName(), c.GetContainerId(), c.GetLabels()) } } return nil diff --git a/internal/commercial/multihost/enrollment.go b/internal/commercial/multihost/enrollment.go index 02652015..b500bf70 100644 --- a/internal/commercial/multihost/enrollment.go +++ b/internal/commercial/multihost/enrollment.go @@ -181,13 +181,25 @@ func (impl *ingestImpl) RegisterAgent(ctx context.Context, req *agentpb.Register }, nil } +type pushStream = grpc.BidiStreamingServer[agentpb.ClientMessage, agentpb.ServerMessage] + +type recvResult struct { + msg *agentpb.ClientMessage + err error +} + +type pushSession struct { + impl *ingestImpl + stream pushStream + agentID string + acks *ackTracker + lastSeenUpd time.Time +} + // Push implements the long-lived bidirectional event stream for an enrolled agent. // Protocol: server sends AuthChallenge → client sends AuthResponse → server enters event loop. -func (impl *ingestImpl) Push(stream grpc.BidiStreamingServer[agentpb.ClientMessage, agentpb.ServerMessage]) error { - logger := impl.deps.Logger +func (impl *ingestImpl) Push(stream pushStream) error { sessions := impl.deps.Sessions - limiter := impl.deps.Limiter - dispatcher := impl.deps.Dispatcher if !impl.allowAttempt(stream.Context()) { return errTooManyAttempts @@ -205,10 +217,54 @@ func (impl *ingestImpl) Push(stream grpc.BidiStreamingServer[agentpb.ClientMessa } // Phase 2: receive auth response. - type recvResult struct { - msg *agentpb.ClientMessage - err error + resp, err := receiveAuth(stream) + if err != nil { + return err + } + ag, err := impl.authenticate(stream.Context(), resp, challenge.Nonce) + if err != nil { + return err + } + + // Phase 3: open session with a cancellable context. + // sessions.CloseStream() calls cancel() which unblocks the recv loop below. + addr := "" + if p, ok := peer.FromContext(stream.Context()); ok { + addr = p.Addr.String() } + streamCtx, cancel := context.WithCancelCause(stream.Context()) + defer cancel(ErrSessionClosed) + // Commands from HTTP handlers are queued here and written by this goroutine + // only, keeping the single-writer invariant on the gRPC stream. + sendCh := make(chan *agentpb.ServerMessage, 16) + if sessions != nil { + tok := sessions.Open(ag.AgentID, cancel, addr, resp.GetCapabilities(), sendCh) + defer sessions.CloseStream(ag.AgentID, tok, "stream_ended") + } + + connectedAt := impl.markConnected(stream.Context(), ag, resp) + + // Phase 4: async recv so we can select on session cancellation. + recvCh := make(chan recvResult, 1) + go func() { + for { + msg, err := stream.Recv() + recvCh <- recvResult{msg, err} + if err != nil { + return + } + } + }() + + s := &pushSession{impl: impl, stream: stream, agentID: ag.AgentID, lastSeenUpd: connectedAt} + s.acks = newAckTracker(time.Now()) + ackTicker := time.NewTicker(ackTick) + defer ackTicker.Stop() + + return s.run(streamCtx, recvCh, sendCh, ackTicker.C) +} + +func receiveAuth(stream pushStream) (*agentpb.AuthResponse, error) { authTimer := time.NewTimer(authenticationTimeout) defer authTimer.Stop() authCh := make(chan recvResult, 1) @@ -219,68 +275,66 @@ func (impl *ingestImpl) Push(stream grpc.BidiStreamingServer[agentpb.ClientMessa var msg *agentpb.ClientMessage select { case <-authTimer.C: - return grpcstatus.Error(codes.DeadlineExceeded, "authentication timed out") + return nil, grpcstatus.Error(codes.DeadlineExceeded, "authentication timed out") case res := <-authCh: if res.err != nil { - return res.err + return nil, res.err } msg = res.msg } authResp, ok := msg.GetPayload().(*agentpb.ClientMessage_Auth) if !ok { - return grpcstatus.Error(codes.InvalidArgument, "expected AuthResponse as first message") + return nil, grpcstatus.Error(codes.InvalidArgument, "expected AuthResponse as first message") } - resp := authResp.Auth + return authResp.Auth, nil +} + +func (impl *ingestImpl) authenticate(ctx context.Context, resp *agentpb.AuthResponse, nonce []byte) (*agent.Agent, error) { + logger := impl.deps.Logger agentID, err := normalizeAgentID(resp.GetAgentId()) if err != nil { - return err + return nil, err } // Load agent and verify signature. - ag, err := impl.deps.AgentStore.Get(stream.Context(), agentID) + ag, err := impl.deps.AgentStore.Get(ctx, agentID) if errors.Is(err, agent.ErrAgentNotFound) { - return grpcstatus.Error(codes.NotFound, "agent not found") + return nil, grpcstatus.Error(codes.NotFound, "agent not found") } if err != nil { logger.Error("Push: load agent failed", "agent_id", agentID, "err", err) - return grpcstatus.Error(codes.Internal, "load agent error") + return nil, grpcstatus.Error(codes.Internal, "load agent error") } - if err := Verify(resp, challenge.Nonce, ag, time.Now()); err != nil { - switch { - case errors.Is(err, ErrAgentRevoked): - logger.Warn("agent.auth_failed", "agent_id", ag.AgentID, "reason", "revoked") - return grpcstatus.Error(codes.PermissionDenied, "agent_revoked") - case errors.Is(err, ErrClockSkew): - logger.Warn("agent.auth_failed", "agent_id", ag.AgentID, "reason", "clock_skew") - return grpcstatus.Error(codes.DeadlineExceeded, "clock_skew") - default: - logger.Warn("agent.auth_failed", "agent_id", ag.AgentID, "reason", "bad_signature") - return grpcstatus.Error(codes.Unauthenticated, "invalid signature") - } + if err := Verify(resp, nonce, ag, time.Now()); err != nil { + return nil, impl.authFailure(ag.AgentID, err) } logger.Debug("agent.auth_ok", "agent_id", ag.AgentID) + return ag, nil +} - // Phase 3: open session with a cancellable context. - // sessions.CloseStream() calls cancel() which unblocks the recv loop below. - addr := "" - if p, ok := peer.FromContext(stream.Context()); ok { - addr = p.Addr.String() - } - streamCtx, cancel := context.WithCancelCause(stream.Context()) - defer cancel(ErrSessionClosed) - // Commands from HTTP handlers are queued here and written by this goroutine - // only, keeping the single-writer invariant on the gRPC stream. - sendCh := make(chan *agentpb.ServerMessage, 16) - if sessions != nil { - tok := sessions.Open(ag.AgentID, cancel, addr, resp.GetCapabilities(), sendCh) - defer sessions.CloseStream(ag.AgentID, tok, "stream_ended") +func (impl *ingestImpl) authFailure(agentID string, err error) error { + logger := impl.deps.Logger + switch { + case errors.Is(err, ErrAgentRevoked): + logger.Warn("agent.auth_failed", "agent_id", agentID, "reason", "revoked") + return grpcstatus.Error(codes.PermissionDenied, "agent_revoked") + case errors.Is(err, ErrClockSkew): + logger.Warn("agent.auth_failed", "agent_id", agentID, "reason", "clock_skew") + return grpcstatus.Error(codes.DeadlineExceeded, "clock_skew") + default: + logger.Warn("agent.auth_failed", "agent_id", agentID, "reason", "bad_signature") + return grpcstatus.Error(codes.Unauthenticated, "invalid signature") } +} + +func (impl *ingestImpl) markConnected(ctx context.Context, ag *agent.Agent, resp *agentpb.AuthResponse) time.Time { + logger := impl.deps.Logger // RegisterRequest.agent_version freezes at enrollment; AuthResponse carries // the running build, so refresh it on every connect. if v := resp.GetAgentVersion(); v != "" && v != ag.AgentVersion { - if err := impl.deps.AgentStore.UpdateAgentVersion(stream.Context(), ag.AgentID, v); err != nil { + if err := impl.deps.AgentStore.UpdateAgentVersion(ctx, ag.AgentID, v); err != nil { logger.Error("Push: UpdateAgentVersion failed", "agent_id", ag.AgentID, "err", err) } else { logger.Info("agent.version_updated", "agent_id", ag.AgentID, "from", ag.AgentVersion, "to", v) @@ -289,56 +343,13 @@ func (impl *ingestImpl) Push(stream grpc.BidiStreamingServer[agentpb.ClientMessa // Mark live on auth so the stale watcher can't reap a freshly (re)connected stream. connectedAt := time.Now() - if err := impl.deps.AgentStore.UpdateLastSeen(stream.Context(), ag.AgentID, connectedAt); err != nil { + if err := impl.deps.AgentStore.UpdateLastSeen(ctx, ag.AgentID, connectedAt); err != nil { logger.Error("Push: initial UpdateLastSeen failed", "agent_id", ag.AgentID, "err", err) } + return connectedAt +} - // Phase 4: async recv so we can select on session cancellation. - recvCh := make(chan recvResult, 1) - go func() { - for { - msg, err := stream.Recv() - recvCh <- recvResult{msg, err} - if err != nil { - return - } - } - }() - - lastSeenUpd := connectedAt - acks := newAckTracker(time.Now()) - ackTicker := time.NewTicker(ackTick) - defer ackTicker.Stop() - - // touchLiveness records that the agent is alive. Any message proves it — - // telemetry, a command reply, even an event we are about to rate-limit — so - // this runs before payload dispatch. Gating it on accepted telemetry let the - // stale watcher reap a session that was streaming perfectly well. - touchLiveness := func() { - if time.Since(lastSeenUpd) <= 20*time.Second { - return - } - if err := impl.deps.AgentStore.UpdateLastSeen(stream.Context(), ag.AgentID, time.Now()); err != nil { - logger.Error("Push: UpdateLastSeen failed", "agent_id", ag.AgentID, "err", err) - } - lastSeenUpd = time.Now() - } - - sendAck := func(seq uint64) error { - if err := stream.Send(&agentpb.ServerMessage{ - Payload: &agentpb.ServerMessage_Ack{ - Ack: &agentpb.EventAck{ - LastEventSeq: seq, - ReceivedAt: timestamppb.Now(), - }, - }, - }); err != nil { - return err - } - acks.sent(seq, time.Now()) - return nil - } - +func (s *pushSession) run(streamCtx context.Context, recvCh <-chan recvResult, sendCh <-chan *agentpb.ServerMessage, ticks <-chan time.Time) error { for { select { case <-streamCtx.Done(): @@ -351,13 +362,13 @@ func (impl *ingestImpl) Push(stream grpc.BidiStreamingServer[agentpb.ClientMessa return grpcstatus.Error(codes.Unavailable, "session_closed") case out := <-sendCh: - if err := stream.Send(out); err != nil { + if err := s.stream.Send(out); err != nil { return err } - case <-ackTicker.C: - if seq, ok := acks.pending(); ok { - if err := sendAck(seq); err != nil { + case <-ticks: + if seq, ok := s.acks.pending(); ok { + if err := s.sendAck(seq); err != nil { return err } } @@ -366,81 +377,129 @@ func (impl *ingestImpl) Push(stream grpc.BidiStreamingServer[agentpb.ClientMessa if res.err != nil { return res.err } - touchLiveness() - - // Command replies are solicited, not telemetry: they bypass the rate - // limiter and the event counters, or a chatty log follow would trip - // the limiter and get the agent throttled. - if resMsg, ok := res.msg.GetPayload().(*agentpb.ClientMessage_Result); ok { - if sessions != nil { - sessions.DeliverResult(ag.AgentID, resMsg.Result) - } - continue - } - // The spool status is not telemetry: it must not be rate-limited, - // and there is nothing for the dispatcher to route. - if stMsg, ok := res.msg.GetPayload().(*agentpb.ClientMessage_Status); ok { - if sessions != nil { - sessions.RecordSpoolStatus(ag.AgentID, stMsg.Status) - } - continue + if err := s.handleMessage(res.msg); err != nil { + return err } + } + } +} - evMsg, ok := res.msg.GetPayload().(*agentpb.ClientMessage_Event) - if !ok { - continue - } - evt := evMsg.Event - - if limiter != nil { - if allowed, retryAfter := limiter.Allow(ag.AgentID); !allowed { - retryMs := uint32(retryAfter.Milliseconds()) // #nosec G115 -- rate-limiter retry delay, always non-negative and sub-second - logger.Warn("agent.rate_limited", - "agent_id", ag.AgentID, - "retry_after_ms", retryMs, - ) - acks.refuse(evt.GetSeq()) - _ = stream.Send(&agentpb.ServerMessage{ - Payload: &agentpb.ServerMessage_Error{ - Error: &agentpb.StreamError{ - Code: "rate_limited", - Message: "event rate limit exceeded", - RetryAfterMs: retryMs, - }, - }, - }) - continue - } - } +func (s *pushSession) handleMessage(msg *agentpb.ClientMessage) error { + sessions := s.impl.deps.Sessions + s.touchLiveness() + + switch p := msg.GetPayload().(type) { + case *agentpb.ClientMessage_Result: + // Command replies are solicited, not telemetry: they bypass the rate + // limiter and the event counters, or a chatty log follow would trip + // the limiter and get the agent throttled. + if sessions != nil { + sessions.DeliverResult(s.agentID, p.Result) + } + case *agentpb.ClientMessage_Status: + // The spool status is not telemetry: it must not be rate-limited, + // and there is nothing for the dispatcher to route. + if sessions != nil { + sessions.RecordSpoolStatus(s.agentID, p.Status) + } + case *agentpb.ClientMessage_Event: + return s.handleEvent(p.Event) + } + return nil +} - acks.accept(evt.GetSeq()) +func (s *pushSession) handleEvent(evt *agentpb.AgentEvent) error { + if s.rateLimited(evt) { + return nil + } - if sessions != nil { - sessions.IncrEvents(ag.AgentID) - } + s.acks.accept(evt.GetSeq()) - if dispatcher != nil { - if err := dispatcher.Dispatch(stream.Context(), ag.AgentID, evt); err != nil { - logger.Error("Push: dispatch failed", "agent_id", ag.AgentID, "err", err) - if errors.Is(err, agentevent.ErrInvalid) { - _ = stream.Send(&agentpb.ServerMessage{ - Payload: &agentpb.ServerMessage_Error{ - Error: &agentpb.StreamError{Code: "invalid_argument", Message: err.Error()}, - }, - }) - } - } - } + if sessions := s.impl.deps.Sessions; sessions != nil { + sessions.IncrEvents(s.agentID) + } - if seq, ok := acks.afterEvent(time.Now()); ok { - if err := sendAck(seq); err != nil { - return err - } - } + s.dispatch(evt) + + if seq, ok := s.acks.afterEvent(time.Now()); ok { + return s.sendAck(seq) + } + return nil +} + +func (s *pushSession) rateLimited(evt *agentpb.AgentEvent) bool { + limiter := s.impl.deps.Limiter + if limiter == nil { + return false + } + allowed, retryAfter := limiter.Allow(s.agentID) + if allowed { + return false + } + retryMs := uint32(retryAfter.Milliseconds()) // #nosec G115 -- rate-limiter retry delay, always non-negative and sub-second + s.impl.deps.Logger.Warn("agent.rate_limited", + "agent_id", s.agentID, + "retry_after_ms", retryMs, + ) + s.acks.refuse(evt.GetSeq()) + _ = s.stream.Send(&agentpb.ServerMessage{ + Payload: &agentpb.ServerMessage_Error{ + Error: &agentpb.StreamError{ + Code: "rate_limited", + Message: "event rate limit exceeded", + RetryAfterMs: retryMs, + }, + }, + }) + return true +} + +func (s *pushSession) dispatch(evt *agentpb.AgentEvent) { + dispatcher := s.impl.deps.Dispatcher + if dispatcher == nil { + return + } + if err := dispatcher.Dispatch(s.stream.Context(), s.agentID, evt); err != nil { + s.impl.deps.Logger.Error("Push: dispatch failed", "agent_id", s.agentID, "err", err) + if errors.Is(err, agentevent.ErrInvalid) { + _ = s.stream.Send(&agentpb.ServerMessage{ + Payload: &agentpb.ServerMessage_Error{ + Error: &agentpb.StreamError{Code: "invalid_argument", Message: err.Error()}, + }, + }) } } } +// touchLiveness records that the agent is alive. Any message proves it: +// telemetry, a command reply, even an event we are about to rate-limit, so +// this runs before payload dispatch. Gating it on accepted telemetry let the +// stale watcher reap a session that was streaming perfectly well. +func (s *pushSession) touchLiveness() { + if time.Since(s.lastSeenUpd) <= 20*time.Second { + return + } + if err := s.impl.deps.AgentStore.UpdateLastSeen(s.stream.Context(), s.agentID, time.Now()); err != nil { + s.impl.deps.Logger.Error("Push: UpdateLastSeen failed", "agent_id", s.agentID, "err", err) + } + s.lastSeenUpd = time.Now() +} + +func (s *pushSession) sendAck(seq uint64) error { + if err := s.stream.Send(&agentpb.ServerMessage{ + Payload: &agentpb.ServerMessage_Ack{ + Ack: &agentpb.EventAck{ + LastEventSeq: seq, + ReceivedAt: timestamppb.Now(), + }, + }, + }); err != nil { + return err + } + s.acks.sent(seq, time.Now()) + return nil +} + func protoRuntimeToString(rt agentpb.Runtime) string { switch rt { case agentpb.Runtime_RUNTIME_DOCKER: diff --git a/internal/commercial/multihost/hardening_cert_test.go b/internal/commercial/multihost/hardening_cert_test.go index aa27a364..024b539b 100644 --- a/internal/commercial/multihost/hardening_cert_test.go +++ b/internal/commercial/multihost/hardening_cert_test.go @@ -161,8 +161,7 @@ func TestPersistentSelfSigned_WritesTheKeypairOnceAndLogsTheFingerprint(t *testi _, err = tls.LoadX509KeyPair(filepath.Join(dir, "grpc-server.crt"), filepath.Join(dir, "grpc-server.key")) require.NoError(t, err, "the key on disk matches the certificate on disk") - fp := multihost.Fingerprint(onDisk) - assert.Equal(t, sha256Hex(onDisk.Raw), fp) + fp := sha256Hex(onDisk.Raw) assert.Contains(t, logs.String(), "fingerprint_sha256="+fp, "the startup log names the fingerprint") assert.Contains(t, logs.String(), "self_signed=true") @@ -249,7 +248,7 @@ func TestPersistentSelfSigned_RegeneratesUnreadableOrExpiredFiles(t *testing.T) assert.NotEqual(t, before, after, "a regenerated certificate has a new fingerprint") onDisk := certFromPEMFile(t, filepath.Join(dir, "grpc-server.crt")) - assert.Equal(t, after, multihost.Fingerprint(onDisk), "the new certificate is persisted") + assert.Equal(t, after, sha256Hex(onDisk.Raw), "the new certificate is persisted") _, err = tls.LoadX509KeyPair(filepath.Join(dir, "grpc-server.crt"), filepath.Join(dir, "grpc-server.key")) require.NoError(t, err, "the new keypair on disk is loadable") assert.True(t, onDisk.NotAfter.After(time.Now().Add(9*365*24*time.Hour)), "the replacement is a fresh ten-year certificate") @@ -276,21 +275,6 @@ func TestPersistentSelfSigned_RefusesAnUnwritableDirectory(t *testing.T) { assert.Contains(t, err.Error(), dir, "the error names the directory") } -// Contract "Empreinte = SHA-256 du certificat DER, hexadécimal minuscule, 64 caractères". -func TestFingerprint_IsTheLowercaseHexSHA256OfTheDER(t *testing.T) { - _, _, a := makeKeypair(t, time.Now().Add(-time.Hour), time.Now().Add(time.Hour)) - _, _, b := makeKeypair(t, time.Now().Add(-time.Hour), time.Now().Add(time.Hour)) - - for _, cert := range []*x509.Certificate{a, b} { - fp := multihost.Fingerprint(cert) - assert.Equal(t, sha256Hex(cert.Raw), fp) - assert.Len(t, fp, 64) - assert.Equal(t, strings.ToLower(fp), fp) - assert.NotContains(t, fp, ":") - } - assert.NotEqual(t, multihost.Fingerprint(a), multihost.Fingerprint(b)) -} - func multiHostFor(t *testing.T, logger *slog.Logger) extpoint.MultiHost { t.Helper() return multihost.NewMultiHost(extpoint.MultiHostDeps{ diff --git a/internal/commercial/multihost/server.go b/internal/commercial/multihost/server.go index 4f9cfd94..ea3b03a4 100644 --- a/internal/commercial/multihost/server.go +++ b/internal/commercial/multihost/server.go @@ -127,13 +127,6 @@ func (s *Server) Start(ctx context.Context, listen string, tlsCfg *tls.Config) e return nil } -// Stop performs a graceful shutdown of the gRPC server. -func (s *Server) Stop() { - if s.grpc != nil { - s.grpc.GracefulStop() - } -} - var errDemoMode = grpcstatus.Error(codes.PermissionDenied, "demo mode: this server does not accept agents") func demoModeUnaryInterceptor(context.Context, any, *grpc.UnaryServerInfo, grpc.UnaryHandler) (any, error) { diff --git a/internal/commercial/multihost/tlsconfig.go b/internal/commercial/multihost/tlsconfig.go index 3ff2de3c..8d006678 100644 --- a/internal/commercial/multihost/tlsconfig.go +++ b/internal/commercial/multihost/tlsconfig.go @@ -61,9 +61,6 @@ func LoadOrGenerateTLS(certFile, keyFile string, hosts []string, dataDir string, return serverTLSConfig(cert), true, nil } -// Fingerprint returns the lowercase hex SHA-256 of a certificate's DER bytes. -func Fingerprint(cert *x509.Certificate) string { return fingerprintDER(cert.Raw) } - func fingerprintDER(der []byte) string { sum := sha256.Sum256(der) return hex.EncodeToString(sum[:]) diff --git a/internal/docker/client.go b/internal/docker/client.go index 604ce33e..9f218f82 100644 --- a/internal/docker/client.go +++ b/internal/docker/client.go @@ -82,7 +82,7 @@ func (c *Client) Connect(ctx context.Context) error { c.mu.Lock() c.connected = false c.mu.Unlock() - return fmt.Errorf("docker ping failed: %w", err) + return &SocketUnavailableError{Err: err} } c.mu.Lock() c.connected = true @@ -96,7 +96,7 @@ func (c *Client) Connect(ctx context.Context) error { func (c *Client) TryConnect(ctx context.Context) error { if err := c.ping(ctx); err != nil { c.SetDisconnected() - return fmt.Errorf("docker ping failed: %w", err) + return &SocketUnavailableError{Err: err} } c.mu.Lock() c.connected = true @@ -180,7 +180,7 @@ func (c *Client) StatsOneShot(ctx context.Context, containerID string) (*Contain return &stats, nil } -// SocketUnavailableError is returned when the Docker socket is not accessible. +// SocketUnavailableError reports a Docker daemon that does not answer, with how to make it reachable. type SocketUnavailableError struct { Err error } diff --git a/internal/docker/client_test.go b/internal/docker/client_test.go index a33f15b3..a7f4e5c4 100644 --- a/internal/docker/client_test.go +++ b/internal/docker/client_test.go @@ -19,7 +19,9 @@ func TestTryConnect_Timeout(t *testing.T) { err = c.TryConnect(context.Background()) elapsed := time.Since(start) - assert.Error(t, err, "TryConnect on unreachable socket must return an error") + var unavailable *SocketUnavailableError + require.ErrorAs(t, err, &unavailable, "TryConnect on unreachable socket must return an error") + assert.Contains(t, err.Error(), "DOCKER_HOST", "the error tells how to reach the daemon") assert.Less(t, elapsed, 5*time.Second, "TryConnect must return in ≤ 5s (bounded ~3s)") assert.False(t, c.IsConnected(), "IsConnected must be false after failed TryConnect") } diff --git a/internal/endpoint/labels.go b/internal/endpoint/labels.go index e3be0e76..3564d1cd 100644 --- a/internal/endpoint/labels.go +++ b/internal/endpoint/labels.go @@ -47,10 +47,6 @@ type LabelParseError struct { Message string } -func (e *LabelParseError) Error() string { - return fmt.Sprintf("label %s=%s: %s", e.LabelKey, e.Value, e.Message) -} - // ParseEndpointLabels extracts endpoint definitions from a Docker container's labels. // Returns parsed endpoints and any configuration errors encountered; an ignored // container declares none. diff --git a/internal/eol/table.go b/internal/eol/table.go index ccce9eed..3b3e562c 100644 --- a/internal/eol/table.go +++ b/internal/eol/table.go @@ -27,11 +27,6 @@ type Date struct { t time.Time } -// NewDate builds a Date from its calendar components. -func NewDate(year int, month time.Month, day int) Date { - return Date{t: time.Date(year, month, day, 0, 0, 0, 0, time.UTC)} -} - // ParseDate reads a YYYY-MM-DD day. func ParseDate(s string) (Date, error) { t, err := time.ParseInLocation(time.DateOnly, s, time.UTC) diff --git a/internal/eol/table_test.go b/internal/eol/table_test.go index 919b5156..8798c0fc 100644 --- a/internal/eol/table_test.go +++ b/internal/eol/table_test.go @@ -72,7 +72,7 @@ func TestValidate(t *testing.T) { t.Run("extended before security", func(t *testing.T) { table := cloneTable(base) - before := NewDate(2000, time.January, 1) + before := newDate(2000, time.January, 1) cycles := table.Products["debian"] cycles[0].ExtendedUntil = &before if err := Validate(table); err == nil { @@ -82,7 +82,7 @@ func TestValidate(t *testing.T) { t.Run("active after security", func(t *testing.T) { table := cloneTable(base) - after := NewDate(2099, time.January, 1) + after := newDate(2099, time.January, 1) cycles := table.Products["debian"] cycles[0].ActiveUntil = &after if err := Validate(table); err == nil { @@ -115,7 +115,7 @@ func TestDateJSON(t *testing.T) { } func TestDateDaysUntil(t *testing.T) { - date := NewDate(2026, time.August, 31) + date := newDate(2026, time.August, 31) cases := map[string]int{ "2026-08-31T00:00:00Z": 0, "2026-08-31T23:59:59Z": 0, @@ -140,3 +140,7 @@ func cloneTable(src Table) Table { } return out } + +func newDate(year int, month time.Month, day int) Date { + return Date{t: time.Date(year, month, day, 0, 0, 0, 0, time.UTC)} +} diff --git a/internal/kubernetes/discovery.go b/internal/kubernetes/discovery.go index 3e968288..d52438d4 100644 --- a/internal/kubernetes/discovery.go +++ b/internal/kubernetes/discovery.go @@ -115,86 +115,43 @@ func (r *Runtime) discoverAll(ctx context.Context) ([]*cmodel.Container, error) } func (r *Runtime) mapDeployment(dep *appsv1.Deployment, now time.Time) *cmodel.Container { - replicas := int32(1) - if dep.Spec.Replicas != nil { - replicas = *dep.Spec.Replicas - } - state, errorDetail := deploymentState(dep) - cm := &cmodel.Container{ - ExternalID: fmt.Sprintf("%s/Deployment/%s", dep.Namespace, dep.Name), - Name: dep.Name, - Image: primaryImage(dep.Spec.Template.Spec.Containers), - State: state, - OrchestrationGroup: dep.Namespace, - OrchestrationUnit: dep.Name, - RuntimeType: "kubernetes", - ControllerKind: "Deployment", - Namespace: dep.Namespace, - PodCount: int(replicas), - ReadyCount: int(dep.Status.ReadyReplicas), - ErrorDetail: errorDetail, - AlertSeverity: cmodel.SeverityWarning, - RestartThreshold: 3, - FirstSeenAt: dep.CreationTimestamp.Time, - LastStateChangeAt: now, - } - applyAnnotations(cm, dep.Annotations) + cm := controllerContainer("Deployment", dep.ObjectMeta, dep.Spec.Template.Spec.Containers, desiredReplicas(dep.Spec.Replicas), dep.Status.ReadyReplicas, now) + cm.State, cm.ErrorDetail = deploymentState(dep) return cm } func (r *Runtime) mapStatefulSet(ss *appsv1.StatefulSet, now time.Time) *cmodel.Container { - replicas := int32(1) - if ss.Spec.Replicas != nil { - replicas = *ss.Spec.Replicas - } - state := cmodel.StateRunning - if ss.Status.ReadyReplicas == 0 && replicas > 0 { - state = cmodel.StateCreated - } - cm := &cmodel.Container{ - ExternalID: fmt.Sprintf("%s/StatefulSet/%s", ss.Namespace, ss.Name), - Name: ss.Name, - Image: primaryImage(ss.Spec.Template.Spec.Containers), - State: state, - OrchestrationGroup: ss.Namespace, - OrchestrationUnit: ss.Name, - RuntimeType: "kubernetes", - ControllerKind: "StatefulSet", - Namespace: ss.Namespace, - PodCount: int(replicas), - ReadyCount: int(ss.Status.ReadyReplicas), - AlertSeverity: cmodel.SeverityWarning, - RestartThreshold: 3, - FirstSeenAt: ss.CreationTimestamp.Time, - LastStateChangeAt: now, - } - applyAnnotations(cm, ss.Annotations) - return cm + return controllerContainer("StatefulSet", ss.ObjectMeta, ss.Spec.Template.Spec.Containers, desiredReplicas(ss.Spec.Replicas), ss.Status.ReadyReplicas, now) } func (r *Runtime) mapDaemonSet(ds *appsv1.DaemonSet, now time.Time) *cmodel.Container { + return controllerContainer("DaemonSet", ds.ObjectMeta, ds.Spec.Template.Spec.Containers, ds.Status.DesiredNumberScheduled, ds.Status.NumberReady, now) +} + +// controllerContainer maps a controller to a container, created while none of its desired pods is ready. +func controllerContainer(kind string, meta metav1.ObjectMeta, containers []corev1.Container, desired, ready int32, now time.Time) *cmodel.Container { state := cmodel.StateRunning - if ds.Status.NumberReady == 0 && ds.Status.DesiredNumberScheduled > 0 { + if ready == 0 && desired > 0 { state = cmodel.StateCreated } cm := &cmodel.Container{ - ExternalID: fmt.Sprintf("%s/DaemonSet/%s", ds.Namespace, ds.Name), - Name: ds.Name, - Image: primaryImage(ds.Spec.Template.Spec.Containers), + ExternalID: workloadID(meta.Namespace, kind, meta.Name), + Name: meta.Name, + Image: primaryImage(containers), State: state, - OrchestrationGroup: ds.Namespace, - OrchestrationUnit: ds.Name, + OrchestrationGroup: meta.Namespace, + OrchestrationUnit: meta.Name, RuntimeType: "kubernetes", - ControllerKind: "DaemonSet", - Namespace: ds.Namespace, - PodCount: int(ds.Status.DesiredNumberScheduled), - ReadyCount: int(ds.Status.NumberReady), + ControllerKind: kind, + Namespace: meta.Namespace, + PodCount: int(desired), + ReadyCount: int(ready), AlertSeverity: cmodel.SeverityWarning, RestartThreshold: 3, - FirstSeenAt: ds.CreationTimestamp.Time, + FirstSeenAt: meta.CreationTimestamp.Time, LastStateChangeAt: now, } - applyAnnotations(cm, ds.Annotations) + applyAnnotations(cm, meta.Annotations) return cm } @@ -231,10 +188,7 @@ func deploymentState(dep *appsv1.Deployment) (cmodel.ContainerState, string) { return cmodel.StateExited, "ProgressDeadlineExceeded: " + cond.Message } } - replicas := int32(1) - if dep.Spec.Replicas != nil { - replicas = *dep.Spec.Replicas - } + replicas := desiredReplicas(dep.Spec.Replicas) if dep.Status.ReadyReplicas >= replicas && replicas > 0 { return cmodel.StateRunning, "" } diff --git a/internal/kubernetes/events.go b/internal/kubernetes/events.go index 19663ef4..c988593c 100644 --- a/internal/kubernetes/events.go +++ b/internal/kubernetes/events.go @@ -72,14 +72,8 @@ func (r *Runtime) streamEvents(ctx context.Context) <-chan runtime.RuntimeEvent } }, DeleteFunc: func(obj interface{}) { - pod, ok := obj.(*corev1.Pod) - if !ok { - tombstone, ok := obj.(cache.DeletedFinalStateUnknown) - if ok { - pod, _ = tombstone.Obj.(*corev1.Pod) - } - } - if pod == nil || hasControllerOwner(pod) { + pod, ok := deletedObject[*corev1.Pod](obj) + if !ok || hasControllerOwner(pod) { return } emit(podToEvent("destroy", pod)) @@ -87,107 +81,57 @@ func (r *Runtime) streamEvents(ctx context.Context) <-chan runtime.RuntimeEvent }, }) - // Deployment events — for controller-level rollout tracking. - _, _ = depInformer.AddEventHandler(cache.FilteringResourceEventHandler{ - FilterFunc: func(obj interface{}) bool { - dep, ok := obj.(*appsv1.Deployment) - return ok && r.nsFilter.IsAllowed(dep.Namespace) - }, - Handler: cache.ResourceEventHandlerFuncs{ - AddFunc: func(obj interface{}) { - dep := obj.(*appsv1.Deployment) - emit(deploymentToEvent("start", dep)) - }, - UpdateFunc: func(_, newObj interface{}) { - dep := newObj.(*appsv1.Deployment) - emit(deploymentToEvent("update", dep)) - }, - DeleteFunc: func(obj interface{}) { - dep, ok := obj.(*appsv1.Deployment) - if !ok { - tombstone, ok := obj.(cache.DeletedFinalStateUnknown) - if ok { - dep, _ = tombstone.Obj.(*appsv1.Deployment) - } - } - if dep != nil { - emit(deploymentToEvent("destroy", dep)) - } - }, - }, - }) + watchController(r, depInformer, emit, deploymentToEvent) + watchController(r, ssInformer, emit, statefulSetToEvent) + watchController(r, dsInformer, emit, daemonSetToEvent) - // StatefulSet events. - _, _ = ssInformer.AddEventHandler(cache.FilteringResourceEventHandler{ - FilterFunc: func(obj interface{}) bool { - ss, ok := obj.(*appsv1.StatefulSet) - return ok && r.nsFilter.IsAllowed(ss.Namespace) - }, - Handler: cache.ResourceEventHandlerFuncs{ - AddFunc: func(obj interface{}) { - ss := obj.(*appsv1.StatefulSet) - emit(statefulSetToEvent("start", ss)) - }, - UpdateFunc: func(_, newObj interface{}) { - ss := newObj.(*appsv1.StatefulSet) - emit(statefulSetToEvent("update", ss)) - }, - DeleteFunc: func(obj interface{}) { - ss, ok := obj.(*appsv1.StatefulSet) - if !ok { - tombstone, ok := obj.(cache.DeletedFinalStateUnknown) - if ok { - ss, _ = tombstone.Obj.(*appsv1.StatefulSet) - } - } - if ss != nil { - emit(statefulSetToEvent("destroy", ss)) - } - }, - }, - }) + stop := make(chan struct{}) + factory.Start(stop) + go func() { + r.waitForLoss(ctx, clientset) + close(stop) + // Shutdown returns once no handler can run, so nothing sends on the closed channel. + factory.Shutdown() + close(out) + }() - // DaemonSet events. - _, _ = dsInformer.AddEventHandler(cache.FilteringResourceEventHandler{ + return out +} + +// watchController emits the start, update and destroy events of one controller kind in the allowed namespaces. +func watchController[T metav1.Object](r *Runtime, informer cache.SharedIndexInformer, emit func(runtime.RuntimeEvent), toEvent func(string, T) runtime.RuntimeEvent) { + _, _ = informer.AddEventHandler(cache.FilteringResourceEventHandler{ FilterFunc: func(obj interface{}) bool { - ds, ok := obj.(*appsv1.DaemonSet) - return ok && r.nsFilter.IsAllowed(ds.Namespace) + o, ok := obj.(T) + return ok && r.nsFilter.IsAllowed(o.GetNamespace()) }, Handler: cache.ResourceEventHandlerFuncs{ AddFunc: func(obj interface{}) { - ds := obj.(*appsv1.DaemonSet) - emit(daemonSetToEvent("start", ds)) + emit(toEvent("start", obj.(T))) }, UpdateFunc: func(_, newObj interface{}) { - ds := newObj.(*appsv1.DaemonSet) - emit(daemonSetToEvent("update", ds)) + emit(toEvent("update", newObj.(T))) }, DeleteFunc: func(obj interface{}) { - ds, ok := obj.(*appsv1.DaemonSet) - if !ok { - tombstone, ok := obj.(cache.DeletedFinalStateUnknown) - if ok { - ds, _ = tombstone.Obj.(*appsv1.DaemonSet) - } - } - if ds != nil { - emit(daemonSetToEvent("destroy", ds)) + if o, ok := deletedObject[T](obj); ok { + emit(toEvent("destroy", o)) } }, }, }) +} - stop := make(chan struct{}) - factory.Start(stop) - go func() { - r.waitForLoss(ctx, clientset) - close(stop) - // Shutdown returns once no handler can run, so nothing sends on the closed channel. - factory.Shutdown() - close(out) - }() - - return out +// deletedObject unwraps the object of a delete notification, tombstone included. +func deletedObject[T any](obj interface{}) (T, bool) { + if o, ok := obj.(T); ok { + return o, true + } + if tombstone, ok := obj.(cache.DeletedFinalStateUnknown); ok { + o, ok := tombstone.Obj.(T) + return o, ok + } + var zero T + return zero, false } // waitForLoss returns when ctx ends, the runtime closes, or probeMisses probes in a row find the API server unreachable. @@ -315,72 +259,36 @@ func podHealthString(pod *corev1.Pod) string { } func deploymentToEvent(action string, dep *appsv1.Deployment) runtime.RuntimeEvent { - state, errorDetail := deploymentState(dep) - _ = state - replicas := int32(1) - if dep.Spec.Replicas != nil { - replicas = *dep.Spec.Replicas - } - healthStatus := "" - if dep.Status.ReadyReplicas >= replicas && replicas > 0 { - healthStatus = "healthy" - } else if dep.Status.ReadyReplicas > 0 { - healthStatus = "starting" - } else if replicas > 0 { - healthStatus = "unhealthy" - } - - return runtime.RuntimeEvent{ - Action: action, - ExternalID: fmt.Sprintf("%s/Deployment/%s", dep.Namespace, dep.Name), - Name: dep.Name, - HealthStatus: healthStatus, - ErrorDetail: errorDetail, - Timestamp: time.Now(), - Labels: dep.Labels, - } + _, errorDetail := deploymentState(dep) + evt := controllerEvent(action, "Deployment", dep.ObjectMeta, dep.Status.ReadyReplicas, desiredReplicas(dep.Spec.Replicas)) + evt.ErrorDetail = errorDetail + return evt } func statefulSetToEvent(action string, ss *appsv1.StatefulSet) runtime.RuntimeEvent { - replicas := int32(1) - if ss.Spec.Replicas != nil { - replicas = *ss.Spec.Replicas - } - healthStatus := "" - if ss.Status.ReadyReplicas >= replicas && replicas > 0 { - healthStatus = "healthy" - } else if ss.Status.ReadyReplicas > 0 { - healthStatus = "starting" - } else if replicas > 0 { - healthStatus = "unhealthy" - } - - return runtime.RuntimeEvent{ - Action: action, - ExternalID: fmt.Sprintf("%s/StatefulSet/%s", ss.Namespace, ss.Name), - Name: ss.Name, - HealthStatus: healthStatus, - Timestamp: time.Now(), - Labels: ss.Labels, - } + return controllerEvent(action, "StatefulSet", ss.ObjectMeta, ss.Status.ReadyReplicas, desiredReplicas(ss.Spec.Replicas)) } func daemonSetToEvent(action string, ds *appsv1.DaemonSet) runtime.RuntimeEvent { - healthStatus := "" - if ds.Status.NumberReady >= ds.Status.DesiredNumberScheduled && ds.Status.DesiredNumberScheduled > 0 { - healthStatus = "healthy" - } else if ds.Status.NumberReady > 0 { - healthStatus = "starting" - } else if ds.Status.DesiredNumberScheduled > 0 { - healthStatus = "unhealthy" - } + return controllerEvent(action, "DaemonSet", ds.ObjectMeta, ds.Status.NumberReady, ds.Status.DesiredNumberScheduled) +} +func controllerEvent(action, kind string, meta metav1.ObjectMeta, ready, desired int32) runtime.RuntimeEvent { + health := "" + switch { + case desired > 0 && ready >= desired: + health = "healthy" + case ready > 0: + health = "starting" + case desired > 0: + health = "unhealthy" + } return runtime.RuntimeEvent{ Action: action, - ExternalID: fmt.Sprintf("%s/DaemonSet/%s", ds.Namespace, ds.Name), - Name: ds.Name, - HealthStatus: healthStatus, + ExternalID: workloadID(meta.Namespace, kind, meta.Name), + Name: meta.Name, + HealthStatus: health, Timestamp: time.Now(), - Labels: ds.Labels, + Labels: meta.Labels, } } diff --git a/internal/kubernetes/services.go b/internal/kubernetes/services.go index 76656eea..3a815b99 100644 --- a/internal/kubernetes/services.go +++ b/internal/kubernetes/services.go @@ -109,7 +109,7 @@ func (r *Runtime) selectableWorkloads(ctx context.Context, cs k8s.Interface) ([] } else { for _, d := range deployments.Items { if keep(d.Namespace) { - out = append(out, selectableWorkload{fmt.Sprintf("%s/Deployment/%s", d.Namespace, d.Name), d.Namespace, d.Spec.Template.Labels}) + out = append(out, selectableWorkload{workloadID(d.Namespace, "Deployment", d.Name), d.Namespace, d.Spec.Template.Labels}) } } } @@ -122,7 +122,7 @@ func (r *Runtime) selectableWorkloads(ctx context.Context, cs k8s.Interface) ([] } else { for _, s := range statefulSets.Items { if keep(s.Namespace) { - out = append(out, selectableWorkload{fmt.Sprintf("%s/StatefulSet/%s", s.Namespace, s.Name), s.Namespace, s.Spec.Template.Labels}) + out = append(out, selectableWorkload{workloadID(s.Namespace, "StatefulSet", s.Name), s.Namespace, s.Spec.Template.Labels}) } } } @@ -135,7 +135,7 @@ func (r *Runtime) selectableWorkloads(ctx context.Context, cs k8s.Interface) ([] } else { for _, d := range daemonSets.Items { if keep(d.Namespace) { - out = append(out, selectableWorkload{fmt.Sprintf("%s/DaemonSet/%s", d.Namespace, d.Name), d.Namespace, d.Spec.Template.Labels}) + out = append(out, selectableWorkload{workloadID(d.Namespace, "DaemonSet", d.Name), d.Namespace, d.Spec.Template.Labels}) } } } diff --git a/internal/kubernetes/workload.go b/internal/kubernetes/workload.go index 3910e0ac..fe03d59c 100644 --- a/internal/kubernetes/workload.go +++ b/internal/kubernetes/workload.go @@ -116,93 +116,85 @@ func (r *Runtime) ListWorkloads(ctx context.Context, namespaces []string) ([]K8s } targetNS := r.resolveNamespaces(namespaces) - // Collect per-namespace workloads. byNS := make(map[string][]K8sWorkload) for _, ns := range targetNS { byNS[ns] = nil } - // Deployments. - depList, err := cs.AppsV1().Deployments("").List(ctx, metav1.ListOptions{}) - if err != nil { - if !k8serrors.IsForbidden(err) { - return nil, fmt.Errorf("list deployments: %w", err) - } - r.logger.Warn("RBAC: forbidden to list deployments", "error", err) - } else { - for i := range depList.Items { - dep := &depList.Items[i] - if !r.nsFilter.IsAllowed(dep.Namespace) { - continue - } - if len(targetNS) > 0 && !containsString(targetNS, dep.Namespace) { - continue - } - byNS[dep.Namespace] = append(byNS[dep.Namespace], mapDeploymentWorkload(dep)) + err = collectWorkloads(r, "deployments", targetNS, byNS, func() ([]appsv1.Deployment, error) { + l, err := cs.AppsV1().Deployments("").List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, err } + return l.Items, nil + }, mapDeploymentWorkload) + if err != nil { + return nil, err } - // StatefulSets. - ssList, err := cs.AppsV1().StatefulSets("").List(ctx, metav1.ListOptions{}) - if err != nil { - if !k8serrors.IsForbidden(err) { - return nil, fmt.Errorf("list statefulsets: %w", err) - } - r.logger.Warn("RBAC: forbidden to list statefulsets", "error", err) - } else { - for i := range ssList.Items { - ss := &ssList.Items[i] - if !r.nsFilter.IsAllowed(ss.Namespace) { - continue - } - if len(targetNS) > 0 && !containsString(targetNS, ss.Namespace) { - continue - } - byNS[ss.Namespace] = append(byNS[ss.Namespace], mapStatefulSetWorkload(ss)) + err = collectWorkloads(r, "statefulsets", targetNS, byNS, func() ([]appsv1.StatefulSet, error) { + l, err := cs.AppsV1().StatefulSets("").List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, err } + return l.Items, nil + }, mapStatefulSetWorkload) + if err != nil { + return nil, err } - // DaemonSets. - dsList, err := cs.AppsV1().DaemonSets("").List(ctx, metav1.ListOptions{}) - if err != nil { - if !k8serrors.IsForbidden(err) { - return nil, fmt.Errorf("list daemonsets: %w", err) + err = collectWorkloads(r, "daemonsets", targetNS, byNS, func() ([]appsv1.DaemonSet, error) { + l, err := cs.AppsV1().DaemonSets("").List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, err } - r.logger.Warn("RBAC: forbidden to list daemonsets", "error", err) - } else { - for i := range dsList.Items { - ds := &dsList.Items[i] - if !r.nsFilter.IsAllowed(ds.Namespace) { - continue - } - if len(targetNS) > 0 && !containsString(targetNS, ds.Namespace) { - continue - } - byNS[ds.Namespace] = append(byNS[ds.Namespace], mapDaemonSetWorkload(ds)) + return l.Items, nil + }, mapDaemonSetWorkload) + if err != nil { + return nil, err + } + + err = collectWorkloads(r, "jobs", targetNS, byNS, func() ([]batchv1.Job, error) { + l, err := cs.BatchV1().Jobs("").List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, err } + return l.Items, nil + }, mapJobWorkload) + if err != nil { + return nil, err } - // Jobs. - jobList, err := cs.BatchV1().Jobs("").List(ctx, metav1.ListOptions{}) + return groupWorkloads(byNS), nil +} + +func collectWorkloads[T any, PT interface { + *T + metav1.Object +}](r *Runtime, kind string, targetNS []string, byNS map[string][]K8sWorkload, list func() ([]T, error), mapFn func(PT) K8sWorkload) error { + items, err := list() if err != nil { if !k8serrors.IsForbidden(err) { - return nil, fmt.Errorf("list jobs: %w", err) + return fmt.Errorf("list %s: %w", kind, err) } - r.logger.Warn("RBAC: forbidden to list jobs", "error", err) - } else { - for i := range jobList.Items { - job := &jobList.Items[i] - if !r.nsFilter.IsAllowed(job.Namespace) { - continue - } - if len(targetNS) > 0 && !containsString(targetNS, job.Namespace) { - continue - } - byNS[job.Namespace] = append(byNS[job.Namespace], mapJobWorkload(job)) + r.logger.Warn("RBAC: forbidden to list "+kind, "error", err) + return nil + } + for i := range items { + item := PT(&items[i]) + ns := item.GetNamespace() + if !r.nsFilter.IsAllowed(ns) { + continue + } + if len(targetNS) > 0 && !containsString(targetNS, ns) { + continue } + byNS[ns] = append(byNS[ns], mapFn(item)) } + return nil +} - // Build sorted groups, skipping empty namespaces. +func groupWorkloads(byNS map[string][]K8sWorkload) []K8sWorkloadGroup { var groups []K8sWorkloadGroup var nsKeys []string for ns := range byNS { @@ -226,8 +218,7 @@ func (r *Runtime) ListWorkloads(ctx context.Context, namespaces []string) ([]K8s Workloads: wls, }) } - - return groups, nil + return groups } // GetWorkload returns the detail for a single workload, its pods, and recent events. @@ -500,152 +491,84 @@ func (r *Runtime) resolveNamespaces(namespaces []string) []string { // --- mapping functions --- func mapDeploymentWorkload(dep *appsv1.Deployment) K8sWorkload { - desired := int32(1) - if dep.Spec.Replicas != nil { - desired = *dep.Spec.Replicas - } - conditions := make([]K8sCondition, 0, len(dep.Status.Conditions)) - var lastTransition time.Time - for _, c := range dep.Status.Conditions { - conditions = append(conditions, K8sCondition{ - Type: string(c.Type), - Status: string(c.Status), - Reason: c.Reason, - Message: c.Message, - LastTransition: c.LastTransitionTime.Time, - }) - if c.LastTransitionTime.After(lastTransition) { - lastTransition = c.LastTransitionTime.Time - } - } - return K8sWorkload{ - ID: fmt.Sprintf("%s/Deployment/%s", dep.Namespace, dep.Name), - Name: dep.Name, - Namespace: dep.Namespace, - Kind: "Deployment", - Images: containerImages(dep.Spec.Template.Spec.Containers), - ReadyReplicas: dep.Status.ReadyReplicas, - DesiredReplicas: desired, - Status: workloadStatus(dep.Status.ReadyReplicas, desired, conditions, "Progressing"), - Conditions: conditions, - Labels: dep.Labels, - Ignored: cmodel.IgnoredByLabels(dep.Annotations), - CreatedAt: dep.CreationTimestamp.Time, - LastTransition: lastTransition, - } + wl := baseWorkload("Deployment", dep.ObjectMeta, dep.Spec.Template.Spec.Containers) + wl.ReadyReplicas = dep.Status.ReadyReplicas + wl.DesiredReplicas = desiredReplicas(dep.Spec.Replicas) + wl.Conditions, wl.LastTransition = collectConditions(dep.Status.Conditions, func(c appsv1.DeploymentCondition) K8sCondition { + return newCondition(string(c.Type), c.Status, c.Reason, c.Message, c.LastTransitionTime) + }) + wl.Status = workloadStatus(wl.ReadyReplicas, wl.DesiredReplicas, wl.Conditions, "Progressing") + return wl } func mapStatefulSetWorkload(ss *appsv1.StatefulSet) K8sWorkload { - desired := int32(1) - if ss.Spec.Replicas != nil { - desired = *ss.Spec.Replicas - } - conditions := make([]K8sCondition, 0, len(ss.Status.Conditions)) - var lastTransition time.Time - for _, c := range ss.Status.Conditions { - conditions = append(conditions, K8sCondition{ - Type: string(c.Type), - Status: string(c.Status), - Reason: c.Reason, - Message: c.Message, - LastTransition: c.LastTransitionTime.Time, - }) - if c.LastTransitionTime.After(lastTransition) { - lastTransition = c.LastTransitionTime.Time - } - } - return K8sWorkload{ - ID: fmt.Sprintf("%s/StatefulSet/%s", ss.Namespace, ss.Name), - Name: ss.Name, - Namespace: ss.Namespace, - Kind: "StatefulSet", - Images: containerImages(ss.Spec.Template.Spec.Containers), - ReadyReplicas: ss.Status.ReadyReplicas, - DesiredReplicas: desired, - Status: workloadStatus(ss.Status.ReadyReplicas, desired, conditions, ""), - Conditions: conditions, - Labels: ss.Labels, - Ignored: cmodel.IgnoredByLabels(ss.Annotations), - CreatedAt: ss.CreationTimestamp.Time, - LastTransition: lastTransition, - } + wl := baseWorkload("StatefulSet", ss.ObjectMeta, ss.Spec.Template.Spec.Containers) + wl.ReadyReplicas = ss.Status.ReadyReplicas + wl.DesiredReplicas = desiredReplicas(ss.Spec.Replicas) + wl.Conditions, wl.LastTransition = collectConditions(ss.Status.Conditions, func(c appsv1.StatefulSetCondition) K8sCondition { + return newCondition(string(c.Type), c.Status, c.Reason, c.Message, c.LastTransitionTime) + }) + wl.Status = workloadStatus(wl.ReadyReplicas, wl.DesiredReplicas, wl.Conditions, "") + return wl } func mapDaemonSetWorkload(ds *appsv1.DaemonSet) K8sWorkload { - desired := ds.Status.DesiredNumberScheduled - conditions := make([]K8sCondition, 0, len(ds.Status.Conditions)) - var lastTransition time.Time - for _, c := range ds.Status.Conditions { - conditions = append(conditions, K8sCondition{ - Type: string(c.Type), - Status: string(c.Status), - Reason: c.Reason, - Message: c.Message, - LastTransition: c.LastTransitionTime.Time, - }) - if c.LastTransitionTime.After(lastTransition) { - lastTransition = c.LastTransitionTime.Time - } - } - return K8sWorkload{ - ID: fmt.Sprintf("%s/DaemonSet/%s", ds.Namespace, ds.Name), - Name: ds.Name, - Namespace: ds.Namespace, - Kind: "DaemonSet", - Images: containerImages(ds.Spec.Template.Spec.Containers), - ReadyReplicas: ds.Status.NumberReady, - DesiredReplicas: desired, - Status: workloadStatus(ds.Status.NumberReady, desired, conditions, ""), - Conditions: conditions, - Labels: ds.Labels, - Ignored: cmodel.IgnoredByLabels(ds.Annotations), - CreatedAt: ds.CreationTimestamp.Time, - LastTransition: lastTransition, - } + wl := baseWorkload("DaemonSet", ds.ObjectMeta, ds.Spec.Template.Spec.Containers) + wl.ReadyReplicas = ds.Status.NumberReady + wl.DesiredReplicas = ds.Status.DesiredNumberScheduled + wl.Conditions, wl.LastTransition = collectConditions(ds.Status.Conditions, func(c appsv1.DaemonSetCondition) K8sCondition { + return newCondition(string(c.Type), c.Status, c.Reason, c.Message, c.LastTransitionTime) + }) + wl.Status = workloadStatus(wl.ReadyReplicas, wl.DesiredReplicas, wl.Conditions, "") + return wl } func mapJobWorkload(job *batchv1.Job) K8sWorkload { - desired := int32(1) - if job.Spec.Completions != nil { - desired = *job.Spec.Completions - } - conditions := make([]K8sCondition, 0, len(job.Status.Conditions)) - var lastTransition time.Time - for _, c := range job.Status.Conditions { - conditions = append(conditions, K8sCondition{ - Type: string(c.Type), - Status: string(c.Status), - Reason: c.Reason, - Message: c.Message, - LastTransition: c.LastTransitionTime.Time, - }) - if c.LastTransitionTime.After(lastTransition) { - lastTransition = c.LastTransitionTime.Time - } - } - - status := "progressing" - if job.Status.Succeeded >= desired { - status = "healthy" + wl := baseWorkload("Job", job.ObjectMeta, job.Spec.Template.Spec.Containers) + wl.ReadyReplicas = job.Status.Active + wl.DesiredReplicas = desiredReplicas(job.Spec.Completions) + wl.Conditions, wl.LastTransition = collectConditions(job.Status.Conditions, func(c batchv1.JobCondition) K8sCondition { + return newCondition(string(c.Type), c.Status, c.Reason, c.Message, c.LastTransitionTime) + }) + wl.Status = "progressing" + if job.Status.Succeeded >= wl.DesiredReplicas { + wl.Status = "healthy" } else if job.Status.Failed > 0 && job.Status.Active == 0 { - status = "failed" + wl.Status = "failed" } + return wl +} +// baseWorkload fills the fields every controller kind shares. +func baseWorkload(kind string, meta metav1.ObjectMeta, containers []corev1.Container) K8sWorkload { return K8sWorkload{ - ID: fmt.Sprintf("%s/Job/%s", job.Namespace, job.Name), - Name: job.Name, - Namespace: job.Namespace, - Kind: "Job", - Images: containerImages(job.Spec.Template.Spec.Containers), - ReadyReplicas: job.Status.Active, - DesiredReplicas: desired, - Status: status, - Conditions: conditions, - Labels: job.Labels, - Ignored: cmodel.IgnoredByLabels(job.Annotations), - CreatedAt: job.CreationTimestamp.Time, - LastTransition: lastTransition, + ID: workloadID(meta.Namespace, kind, meta.Name), + Name: meta.Name, + Namespace: meta.Namespace, + Kind: kind, + Images: containerImages(containers), + Labels: meta.Labels, + Ignored: cmodel.IgnoredByLabels(meta.Annotations), + CreatedAt: meta.CreationTimestamp.Time, + } +} + +func newCondition(typ string, status corev1.ConditionStatus, reason, message string, at metav1.Time) K8sCondition { + return K8sCondition{Type: typ, Status: string(status), Reason: reason, Message: message, LastTransition: at.Time} +} + +// collectConditions converts controller conditions and returns them with the latest transition time. +func collectConditions[C any](conds []C, convert func(C) K8sCondition) ([]K8sCondition, time.Time) { + out := make([]K8sCondition, 0, len(conds)) + var last time.Time + for _, c := range conds { + k := convert(c) + out = append(out, k) + if k.LastTransition.After(last) { + last = k.LastTransition + } } + return out, last } func mapPod(pod *corev1.Pod) K8sPod { @@ -726,11 +649,11 @@ func podWorkloadRef(pod *corev1.Pod) string { switch ref.Kind { case "ReplicaSet": if hash := pod.Labels[appsv1.DefaultDeploymentUniqueLabelKey]; hash != "" && strings.HasSuffix(ref.Name, "-"+hash) { - return fmt.Sprintf("%s/Deployment/%s", pod.Namespace, strings.TrimSuffix(ref.Name, "-"+hash)) + return workloadID(pod.Namespace, "Deployment", strings.TrimSuffix(ref.Name, "-"+hash)) } - return fmt.Sprintf("%s/ReplicaSet/%s", pod.Namespace, ref.Name) + return workloadID(pod.Namespace, "ReplicaSet", ref.Name) case "StatefulSet", "DaemonSet", "Job": - return fmt.Sprintf("%s/%s/%s", pod.Namespace, ref.Kind, ref.Name) + return workloadID(pod.Namespace, ref.Kind, ref.Name) } } return "" @@ -759,6 +682,19 @@ func workloadStatus(ready, desired int32, conditions []K8sCondition, progressing return "degraded" } +// workloadID identifies a controller as namespace/Kind/name. +func workloadID(namespace, kind, name string) string { + return namespace + "/" + kind + "/" + name +} + +// desiredReplicas reads an optional replica count, which Kubernetes defaults to 1. +func desiredReplicas(n *int32) int32 { + if n == nil { + return 1 + } + return *n +} + func containerImages(containers []corev1.Container) []string { images := make([]string, 0, len(containers)) seen := make(map[string]bool, len(containers)) diff --git a/internal/mcp/oauth/server.go b/internal/mcp/oauth/server.go index f499bf8e..4eb57303 100644 --- a/internal/mcp/oauth/server.go +++ b/internal/mcp/oauth/server.go @@ -72,12 +72,6 @@ func NewOAuthServer(cfg Config, store MCPOAuthStore, logger *slog.Logger) *OAuth } } -// isRedirectURIAllowed returns true when uri is a loopback address or matches -// an allowed redirect URI exactly (simple string comparison, RFC 6749 §3.1.2.3). -func (s *OAuthServer) isRedirectURIAllowed(rawURI string) bool { - return s.redirectTarget(rawURI) != nil -} - var loopbackHosts = map[string]string{"localhost": "localhost", "127.0.0.1": "127.0.0.1", "::1": "[::1]"} var redirectSchemes = map[string]string{"http": "http", "https": "https"} diff --git a/internal/mcp/oauth/server_test.go b/internal/mcp/oauth/server_test.go index 15d95f6a..9b619ded 100644 --- a/internal/mcp/oauth/server_test.go +++ b/internal/mcp/oauth/server_test.go @@ -63,8 +63,8 @@ func TestIsRedirectURIAllowed(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := s.isRedirectURIAllowed(tt.uri); got != tt.want { - t.Errorf("isRedirectURIAllowed(%q) = %v, want %v", tt.uri, got, tt.want) + if got := s.redirectTarget(tt.uri) != nil; got != tt.want { + t.Errorf("redirectTarget(%q) allowed = %v, want %v", tt.uri, got, tt.want) } }) } diff --git a/internal/mcp/tools_channels.go b/internal/mcp/tools_channels.go index 16a5c767..d2af01bc 100644 --- a/internal/mcp/tools_channels.go +++ b/internal/mcp/tools_channels.go @@ -142,14 +142,6 @@ func channelValidator(svc *Services, chType string) (alert.ChannelValidator, boo return svc.ChannelValidators.Validator(chType) } -func normalizeConfig(config string) string { - config = strings.TrimSpace(config) - if config == "null" { - return "" - } - return config -} - func (s *Services) broadcast(eventType string, data any) { if s.Broadcast != nil { s.Broadcast(eventType, data) @@ -246,7 +238,7 @@ func createChannelHandler(svc *Services) gomcp.ToolHandlerFor[createChannelInput if _, err := alert.ParseHeaders(input.Headers); err != nil { return errResult("field=headers: " + err.Error()) } - config := normalizeConfig(input.Config) + config := alert.NormalizeConfig(input.Config) if err := validateChannelCredentials(svc, input.Type, input.Secret, config); err != nil { return errResult(err.Error()) } @@ -294,56 +286,29 @@ func updateChannelHandler(svc *Services) gomcp.ToolHandlerFor[updateChannelInput return errResult("channel not found") } - // Switching a channel off never needs the edition that opens its type: - // a downgraded instance must still be able to silence it. - extends := input.Name != nil || input.Type != nil || input.URL != nil || - input.Headers != nil || input.Secret != nil || input.Config != nil || - (input.Enabled != nil && *input.Enabled) - if extends { + patch := alert.ChannelPatch{ + Name: input.Name, Type: input.Type, URL: input.URL, Headers: input.Headers, + Secret: input.Secret, Config: input.Config, Enabled: input.Enabled, + } + if patch.Extends() { if r, v, err := refuseChannelType(ch.Type); r != nil { return r, v, err } - if input.Type != nil { - if r, v, err := refuseChannelType(*input.Type); r != nil { + if patch.Type != nil { + if r, v, err := refuseChannelType(*patch.Type); r != nil { return r, v, err } } } - - if input.Name != nil { - ch.Name = *input.Name - } - if input.Type != nil { - ch.Type = *input.Type - } - if input.URL != nil { - ch.URL = *input.URL - } - if input.Headers != nil { - if _, err := alert.ParseHeaders(*input.Headers); err != nil { - return errResult("field=headers: " + err.Error()) - } - ch.Headers = *input.Headers - } - if input.Secret != nil { - if *input.Secret == "" { - return errResult("field=secret: cannot be cleared; delete the channel instead") - } - ch.Secret = *input.Secret - } - if input.Config != nil { - ch.Config = normalizeConfig(*input.Config) - } - if input.Enabled != nil { - ch.Enabled = *input.Enabled + if err := patch.Apply(ch); err != nil { + return errResult(err.Error()) } - - if input.Secret != nil || input.Config != nil { + if patch.TouchesCredentials() { if err := validateChannelCredentials(svc, ch.Type, ch.Secret, ch.Config); err != nil { return errResult(err.Error()) } } - if input.URL != nil || input.Type != nil { + if patch.TouchesDestination() { if err := validateChannelDestination(ctx, svc, ch.Type, ch.URL); err != nil { return errResult("field=url: " + err.Error()) } diff --git a/internal/retry/backoff.go b/internal/retry/backoff.go index 475b915b..adc22665 100644 --- a/internal/retry/backoff.go +++ b/internal/retry/backoff.go @@ -7,7 +7,6 @@ package retry import ( - "context" "math/rand" "time" ) @@ -64,14 +63,3 @@ func (b *Backoff) Next() time.Duration { factor := 1 - b.Jitter + rand.Float64()*(2*b.Jitter) // #nosec G404 -- non-crypto jitter for backoff timing return time.Duration(float64(raw) * factor) } - -// Sleep waits for the next backoff delay or until ctx is cancelled. -// Returns ctx.Err() on cancellation, nil if the delay elapsed normally. -func (b *Backoff) Sleep(ctx context.Context) error { - select { - case <-ctx.Done(): - return ctx.Err() - case <-time.After(b.Next()): - return nil - } -} diff --git a/internal/security/service.go b/internal/security/service.go index b8f9632b..507d7e76 100644 --- a/internal/security/service.go +++ b/internal/security/service.go @@ -8,7 +8,6 @@ import ( "log/slog" "strings" "sync" - "time" ) // AlertCallback is called when security insights change and an alert should be fired. @@ -269,8 +268,3 @@ func (s *Service) hasChange(old map[InsightType]bool, new map[InsightType]bool) } return false } - -// Now returns the current time (extracted for testing). -func Now() time.Time { - return time.Now() -} diff --git a/internal/status/service.go b/internal/status/service.go index a9684012..895658ac 100644 --- a/internal/status/service.go +++ b/internal/status/service.go @@ -220,11 +220,7 @@ func (s *Service) DeriveComponentStatus(ctx context.Context, c *Component) strin return ComputeAggregateStatus(states) } -// Severity returns a numeric severity for status comparison (higher = worse). -func Severity(s string) int { - return statusSeverity(s) -} - +// statusSeverity returns a numeric severity for status comparison (higher = worse). func statusSeverity(s string) int { switch s { case StatusMajorOutage: diff --git a/internal/status/service_test.go b/internal/status/service_test.go index 13214829..321d874a 100644 --- a/internal/status/service_test.go +++ b/internal/status/service_test.go @@ -365,7 +365,7 @@ func TestService_ComputeGlobalStatus_NoComponents(t *testing.T) { assert.Equal(t, GlobalAllOperational, msg) } -// --- statusSeverity / Severity --- +// --- statusSeverity --- func TestStatusSeverity_Values(t *testing.T) { cases := []struct { @@ -382,7 +382,6 @@ func TestStatusSeverity_Values(t *testing.T) { for _, tc := range cases { t.Run(tc.status, func(t *testing.T) { assert.Equal(t, tc.expected, statusSeverity(tc.status)) - assert.Equal(t, tc.expected, Severity(tc.status), "exported Severity must match") }) } } diff --git a/internal/store/agents.go b/internal/store/agents.go index bea2bb77..1cddb7e8 100644 --- a/internal/store/agents.go +++ b/internal/store/agents.go @@ -498,12 +498,7 @@ func (s *AgentStore) GcExpiredTokens(ctx context.Context) error { return nil } -// scanner is satisfied by both *sql.Row and *sql.Rows. -type scanner interface { - Scan(dest ...any) error -} - -func scanAgent(s scanner) (*agent.Agent, error) { +func scanAgent(s rowScanner) (*agent.Agent, error) { a := &agent.Agent{} var lastSeen, revokedAt, osReportedAt sql.NullInt64 var revokedBy sql.NullString @@ -535,7 +530,7 @@ func scanAgent(s scanner) (*agent.Agent, error) { return a, nil } -func scanToken(s scanner) (*agent.EnrollmentToken, error) { +func scanToken(s rowScanner) (*agent.EnrollmentToken, error) { t := &agent.EnrollmentToken{} var consumedAt sql.NullInt64 var consumedBy sql.NullString diff --git a/internal/store/certificates.go b/internal/store/certificates.go index 08215a4d..d367e190 100644 --- a/internal/store/certificates.go +++ b/internal/store/certificates.go @@ -148,7 +148,7 @@ func (s *CertificateStore) ListMonitors(ctx context.Context, opts certificate.Li var monitors []*certificate.CertMonitor for rows.Next() { - m, err := s.scanMonitorRow(rows) + m, err := s.scanMonitor(rows) if err != nil { return nil, err } @@ -270,47 +270,19 @@ func (s *CertificateStore) GetLatestCheckResult(ctx context.Context, monitorID s } func (s *CertificateStore) ListCheckResults(ctx context.Context, monitorID string, opts certificate.ListChecksOpts) ([]*certificate.CertCheckResult, int, error) { - var total int - if err := s.db.QueryRowContext(ctx, - `SELECT COUNT(*) FROM cert_check_results WHERE monitor_id=?`, monitorID).Scan(&total); err != nil { - return nil, 0, fmt.Errorf("count cert check results: %w", err) - } - - limit := opts.Limit - if limit <= 0 { - limit = 50 - } - if limit > 500 { - limit = 500 - } - - query := `SELECT id, monitor_id, subject_cn, issuer_cn, issuer_org, sans_json, + return listPage(ctx, s.db, pageQuery{ + what: "cert check results", + countSQL: `SELECT COUNT(*) FROM cert_check_results WHERE monitor_id=?`, + selectSQL: `SELECT id, monitor_id, subject_cn, issuer_cn, issuer_org, sans_json, serial_number, signature_algorithm, not_before, not_after, chain_valid, chain_error, hostname_match, error_message, checked_at, ocsp_stapled, ocsp_status, ocsp_produced_at, ocsp_next_update, ocsp_error - FROM cert_check_results WHERE monitor_id=? ORDER BY checked_at DESC` - query += fmt.Sprintf(` LIMIT %d`, limit) - if opts.Offset > 0 { - query += fmt.Sprintf(` OFFSET %d`, opts.Offset) // #nosec G202 -- integer formatting of an int option. - } - - rows, err := s.db.QueryContext(ctx, query, monitorID) - if err != nil { - return nil, 0, fmt.Errorf("list cert check results: %w", err) - } - defer func(rows *sql.Rows) { - _ = rows.Close() - }(rows) - - var results []*certificate.CertCheckResult - for rows.Next() { - r, err := s.scanCheckResultRow(rows) - if err != nil { - return nil, 0, err - } - results = append(results, r) - } - return results, total, rows.Err() + FROM cert_check_results WHERE monitor_id=? ORDER BY checked_at DESC`, + args: []any{monitorID}, + limit: opts.Limit, + offset: opts.Offset, + defaultLimit: 50, + }, s.scanCheckResult) } // --- Chain entries --- @@ -374,7 +346,7 @@ func (s *CertificateStore) ListDueScheduledMonitors(ctx context.Context, now tim var monitors []*certificate.CertMonitor for rows.Next() { - m, err := s.scanMonitorRow(rows) + m, err := s.scanMonitor(rows) if err != nil { return nil, err } @@ -398,7 +370,7 @@ func (s *CertificateStore) ListMonitorsByExternalID(ctx context.Context, agentID var monitors []*certificate.CertMonitor for rows.Next() { - m, err := s.scanMonitorRow(rows) + m, err := s.scanMonitor(rows) if err != nil { return nil, err } @@ -487,10 +459,6 @@ func (s *CertificateStore) scanMonitor(row rowScanner) (*certificate.CertMonitor return &m, nil } -func (s *CertificateStore) scanMonitorRow(rows *sql.Rows) (*certificate.CertMonitor, error) { - return s.scanMonitor(rows) -} - func (s *CertificateStore) scanCheckResult(row rowScanner) (*certificate.CertCheckResult, error) { var r certificate.CertCheckResult var subjectCN, issuerCN, issuerOrg, sansJSON, serialNumber, sigAlgo sql.NullString @@ -578,10 +546,6 @@ func (s *CertificateStore) scanCheckResult(row rowScanner) (*certificate.CertChe return &r, nil } -func (s *CertificateStore) scanCheckResultRow(rows *sql.Rows) (*certificate.CertCheckResult, error) { - return s.scanCheckResult(rows) -} - // CountConfigured returns the number of certificate monitors. Operator-created // and auto-detected entries are both counted; the table uses hard-delete only, // so no soft-delete filter is needed. diff --git a/internal/store/channels.go b/internal/store/channels.go index c4deadf5..53bfb0c5 100644 --- a/internal/store/channels.go +++ b/internal/store/channels.go @@ -73,7 +73,7 @@ func (s *ChannelStoreImpl) ListChannels(ctx context.Context) ([]*alert.Notificat var channels []*alert.NotificationChannel for rows.Next() { - ch, err := scanChannelRow(rows) + ch, err := scanChannel(rows) if err != nil { return nil, err } @@ -194,7 +194,7 @@ func (s *ChannelStoreImpl) ListDeliveriesByAlert(ctx context.Context, alertID st // --- Scan helpers --- -func scanChannel(row *sql.Row) (*alert.NotificationChannel, error) { +func scanChannel(row rowScanner) (*alert.NotificationChannel, error) { ch := &alert.NotificationChannel{} var headers, secret, config sql.NullString var enabled int @@ -216,26 +216,3 @@ func scanChannel(row *sql.Row) (*alert.NotificationChannel, error) { ch.UpdatedAt = time.Unix(updatedAt, 0) return ch, nil } - -func scanChannelRow(rows *sql.Rows) (*alert.NotificationChannel, error) { - ch := &alert.NotificationChannel{} - var headers, secret, config sql.NullString - var enabled int - var createdAt, updatedAt int64 - - err := rows.Scan(&ch.ID, &ch.Name, &ch.Type, &ch.URL, &headers, &secret, &config, &enabled, &createdAt, &updatedAt) - if err != nil { - return nil, err - } - - ch.Enabled = enabled == 1 - if headers.Valid { - ch.Headers = headers.String - } - ch.Secret = secret.String - ch.Config = config.String - ch.HasSecret = ch.Secret != "" - ch.CreatedAt = time.Unix(createdAt, 0) - ch.UpdatedAt = time.Unix(updatedAt, 0) - return ch, nil -} diff --git a/internal/store/containers.go b/internal/store/containers.go index 0b845b72..b83a01ba 100644 --- a/internal/store/containers.go +++ b/internal/store/containers.go @@ -150,7 +150,7 @@ func (s *ContainerStore) ListContainers(ctx context.Context, opts container.List var containers []*container.Container for rows.Next() { - c, err := s.scanContainerRow(rows) + c, err := s.scanContainer(rows) if err != nil { return nil, err } @@ -388,10 +388,6 @@ const containerColumns = `id, agent_id, external_id, name, image, state, health_ const transitionColumns = `id, container_id, previous_state, new_state, previous_health, new_health, exit_code, log_snippet, timestamp` -type rowScanner interface { - Scan(dest ...interface{}) error -} - func (s *ContainerStore) scanContainer(row rowScanner) (*container.Container, error) { var c container.Container var healthStatus, orchestrationGroup, orchestrationUnit, customGroup sql.NullString @@ -445,10 +441,6 @@ func (s *ContainerStore) scanContainer(row rowScanner) (*container.Container, er return &c, nil } -func (s *ContainerStore) scanContainerRow(rows *sql.Rows) (*container.Container, error) { - return s.scanContainer(rows) -} - func scanTransitionRow(rows rowScanner) (*container.StateTransition, error) { var t container.StateTransition var prevHealth, newHealth sql.NullString diff --git a/internal/store/endpoints.go b/internal/store/endpoints.go index 5765dff8..ce4ae9cd 100644 --- a/internal/store/endpoints.go +++ b/internal/store/endpoints.go @@ -135,7 +135,7 @@ func (s *EndpointStore) ListEndpoints(ctx context.Context, opts endpoint.ListEnd var endpoints []*endpoint.Endpoint for rows.Next() { - e, err := s.scanEndpointRow(rows) + e, err := s.scanEndpoint(rows) if err != nil { return nil, err } @@ -157,7 +157,7 @@ func (s *EndpointStore) ListEndpointsByExternalID(ctx context.Context, agentID, var endpoints []*endpoint.Endpoint for rows.Next() { - e, err := s.scanEndpointRow(rows) + e, err := s.scanEndpoint(rows) if err != nil { return nil, err } @@ -227,55 +227,24 @@ func (s *EndpointStore) InsertCheckResult(ctx context.Context, result *endpoint. } func (s *EndpointStore) ListCheckResults(ctx context.Context, endpointID string, opts endpoint.ListChecksOpts) ([]*endpoint.CheckResult, int, error) { - countQuery := `SELECT COUNT(*) FROM check_results WHERE endpoint_id=?` - countArgs := []interface{}{endpointID} - - query := `SELECT cr.id, cr.endpoint_id, cr.success, cr.response_time_ms, cr.http_status, cr.error_message, cr.timestamp, e.agent_id + countSQL := `SELECT COUNT(*) FROM check_results WHERE endpoint_id=?` + selectSQL := `SELECT cr.id, cr.endpoint_id, cr.success, cr.response_time_ms, cr.http_status, cr.error_message, cr.timestamp, e.agent_id FROM check_results cr JOIN endpoints e ON e.id = cr.endpoint_id WHERE cr.endpoint_id=?` - args := []interface{}{endpointID} - + args := []any{endpointID} if opts.Since != nil { - query += ` AND cr.timestamp>=?` + countSQL += ` AND timestamp>=?` + selectSQL += ` AND cr.timestamp>=?` args = append(args, opts.Since.Unix()) - countQuery += ` AND timestamp>=?` - countArgs = append(countArgs, opts.Since.Unix()) - } - - var total int - if err := s.db.QueryRowContext(ctx, countQuery, countArgs...).Scan(&total); err != nil { - return nil, 0, fmt.Errorf("count check results: %w", err) - } - - query += ` ORDER BY cr.timestamp DESC` - limit := opts.Limit - if limit <= 0 { - limit = 50 - } - if limit > 500 { - limit = 500 - } - query += fmt.Sprintf(` LIMIT %d`, limit) - if opts.Offset > 0 { - query += fmt.Sprintf(` OFFSET %d`, opts.Offset) // #nosec G202 -- integer formatting of an int option. } - - rows, err := s.db.QueryContext(ctx, query, args...) - if err != nil { - return nil, 0, fmt.Errorf("list check results: %w", err) - } - defer func(rows *sql.Rows) { - _ = rows.Close() - }(rows) - - var results []*endpoint.CheckResult - for rows.Next() { - r, err := scanCheckResultRow(rows) - if err != nil { - return nil, 0, err - } - results = append(results, r) - } - return results, total, rows.Err() + return listPage(ctx, s.db, pageQuery{ + what: "check results", + countSQL: countSQL, + selectSQL: selectSQL + ` ORDER BY cr.timestamp DESC`, + args: args, + limit: opts.Limit, + offset: opts.Offset, + defaultLimit: 50, + }, scanCheckResultRow) } func (s *EndpointStore) GetCheckResultsInWindow(ctx context.Context, endpointID string, from, to time.Time) (int, int, error) { @@ -458,10 +427,6 @@ func (s *EndpointStore) scanEndpoint(row rowScanner) (*endpoint.Endpoint, error) return &e, nil } -func (s *EndpointStore) scanEndpointRow(rows *sql.Rows) (*endpoint.Endpoint, error) { - return s.scanEndpoint(rows) -} - func scanCheckResultRow(row rowScanner) (*endpoint.CheckResult, error) { var r endpoint.CheckResult var success int diff --git a/internal/store/errors.go b/internal/store/errors.go index 06650339..cc8f9380 100644 --- a/internal/store/errors.go +++ b/internal/store/errors.go @@ -46,17 +46,6 @@ func IsUniqueViolation(err error) bool { return errors.As(err, &pe) && pe.Code == "23505" } -// IsForeignKeyViolation reports whether err is a foreign-key constraint -// violation, on either engine. -func IsForeignKeyViolation(err error) bool { - var se sqlite3.Error - if errors.As(err, &se) { - return se.ExtendedCode == sqlite3.ErrConstraintForeignKey - } - var pe *pgconn.PgError - return errors.As(err, &pe) && pe.Code == "23503" -} - // IsBusy reports whether err is a transient contention failure worth retrying: // SQLITE_BUSY/SQLITE_LOCKED, a PostgreSQL lock timeout (55P03) or a // serialization failure (40001). diff --git a/internal/store/errors_test.go b/internal/store/errors_test.go index 146c4d93..0b839a12 100644 --- a/internal/store/errors_test.go +++ b/internal/store/errors_test.go @@ -41,14 +41,6 @@ func TestIsUniqueViolation(t *testing.T) { assert.False(t, IsUniqueViolation(nil)) } -func TestIsForeignKeyViolation(t *testing.T) { - assert.True(t, IsForeignKeyViolation(sqliteErr(sqlite3.ErrConstraint, sqlite3.ErrConstraintForeignKey))) - assert.True(t, IsForeignKeyViolation(pgErr("23503"))) - assert.False(t, IsForeignKeyViolation(sqliteErr(sqlite3.ErrConstraint, sqlite3.ErrConstraintUnique))) - assert.False(t, IsForeignKeyViolation(pgErr("23505"))) - assert.False(t, IsForeignKeyViolation(nil)) -} - func TestIsBusy(t *testing.T) { assert.True(t, IsBusy(sqliteErr(sqlite3.ErrBusy, 0))) assert.True(t, IsBusy(sqliteErr(sqlite3.ErrLocked, 0))) diff --git a/internal/store/heartbeats.go b/internal/store/heartbeats.go index 191b13f7..d3e8ca64 100644 --- a/internal/store/heartbeats.go +++ b/internal/store/heartbeats.go @@ -103,7 +103,7 @@ func (s *HeartbeatStore) ListHeartbeats(ctx context.Context, opts heartbeat.List var results []*heartbeat.Heartbeat for rows.Next() { - h, err := s.scanHeartbeatRow(rows) + h, err := s.scanHeartbeat(rows) if err != nil { return nil, err } @@ -250,7 +250,7 @@ func (s *HeartbeatStore) ListOverdueHeartbeats(ctx context.Context, now time.Tim var results []*heartbeat.Heartbeat for rows.Next() { - h, err := s.scanHeartbeatRow(rows) + h, err := s.scanHeartbeat(rows) if err != nil { return nil, err } @@ -291,44 +291,16 @@ func (s *HeartbeatStore) InsertPing(ctx context.Context, p *heartbeat.HeartbeatP } func (s *HeartbeatStore) ListPings(ctx context.Context, heartbeatID string, opts heartbeat.ListPingsOpts) ([]*heartbeat.HeartbeatPing, int, error) { - var total int - if err := s.db.QueryRowContext(ctx, - `SELECT COUNT(*) FROM heartbeat_pings WHERE heartbeat_id=?`, heartbeatID).Scan(&total); err != nil { - return nil, 0, fmt.Errorf("count pings: %w", err) - } - - limit := opts.Limit - if limit <= 0 { - limit = 50 - } - if limit > 500 { - limit = 500 - } - - query := `SELECT id, heartbeat_id, ping_type, exit_code, source_ip, http_method, payload, timestamp - FROM heartbeat_pings WHERE heartbeat_id=? ORDER BY timestamp DESC` - query += fmt.Sprintf(` LIMIT %d`, limit) - if opts.Offset > 0 { - query += fmt.Sprintf(` OFFSET %d`, opts.Offset) // #nosec G202 -- integer formatting of an int option. - } - - rows, err := s.db.QueryContext(ctx, query, heartbeatID) - if err != nil { - return nil, 0, fmt.Errorf("list pings: %w", err) - } - defer func(rows *sql.Rows) { - _ = rows.Close() - }(rows) - - var results []*heartbeat.HeartbeatPing - for rows.Next() { - p, err := scanPingRow(rows) - if err != nil { - return nil, 0, err - } - results = append(results, p) - } - return results, total, rows.Err() + return listPage(ctx, s.db, pageQuery{ + what: "pings", + countSQL: `SELECT COUNT(*) FROM heartbeat_pings WHERE heartbeat_id=?`, + selectSQL: `SELECT id, heartbeat_id, ping_type, exit_code, source_ip, http_method, payload, timestamp + FROM heartbeat_pings WHERE heartbeat_id=? ORDER BY timestamp DESC`, + args: []any{heartbeatID}, + limit: opts.Limit, + offset: opts.Offset, + defaultLimit: 50, + }, scanPingRow) } // --- Executions --- @@ -373,51 +345,27 @@ func (s *HeartbeatStore) UpdateExecution(ctx context.Context, id string, complet // GetCurrentExecution returns the latest in-progress execution. Execution ids // are time-ordered UUIDv7, so ORDER BY id DESC yields the most recent. func (s *HeartbeatStore) GetCurrentExecution(ctx context.Context, heartbeatID string) (*heartbeat.HeartbeatExecution, error) { - return scanExecutionSingle(s.db.QueryRowContext(ctx, + e, err := scanExecution(s.db.QueryRowContext(ctx, `SELECT id, heartbeat_id, started_at, completed_at, duration_ms, exit_code, outcome, payload FROM heartbeat_executions WHERE heartbeat_id=? AND outcome='in_progress' ORDER BY id DESC LIMIT 1`, heartbeatID)) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return e, err } func (s *HeartbeatStore) ListExecutions(ctx context.Context, heartbeatID string, opts heartbeat.ListExecutionsOpts) ([]*heartbeat.HeartbeatExecution, int, error) { - var total int - if err := s.db.QueryRowContext(ctx, - `SELECT COUNT(*) FROM heartbeat_executions WHERE heartbeat_id=?`, heartbeatID).Scan(&total); err != nil { - return nil, 0, fmt.Errorf("count executions: %w", err) - } - - limit := opts.Limit - if limit <= 0 { - limit = 20 - } - if limit > 500 { - limit = 500 - } - - query := `SELECT id, heartbeat_id, started_at, completed_at, duration_ms, exit_code, outcome, payload - FROM heartbeat_executions WHERE heartbeat_id=? ORDER BY id DESC` - query += fmt.Sprintf(` LIMIT %d`, limit) - if opts.Offset > 0 { - query += fmt.Sprintf(` OFFSET %d`, opts.Offset) // #nosec G202 -- integer formatting of an int option. - } - - rows, err := s.db.QueryContext(ctx, query, heartbeatID) - if err != nil { - return nil, 0, fmt.Errorf("list executions: %w", err) - } - defer func(rows *sql.Rows) { - _ = rows.Close() - }(rows) - - var results []*heartbeat.HeartbeatExecution - for rows.Next() { - e, err := scanExecutionRow(rows) - if err != nil { - return nil, 0, err - } - results = append(results, e) - } - return results, total, rows.Err() + return listPage(ctx, s.db, pageQuery{ + what: "executions", + countSQL: `SELECT COUNT(*) FROM heartbeat_executions WHERE heartbeat_id=?`, + selectSQL: `SELECT id, heartbeat_id, started_at, completed_at, duration_ms, exit_code, outcome, payload + FROM heartbeat_executions WHERE heartbeat_id=? ORDER BY id DESC`, + args: []any{heartbeatID}, + limit: opts.Limit, + offset: opts.Offset, + defaultLimit: 20, + }, scanExecution) } // --- Retention --- @@ -495,10 +443,6 @@ func (s *HeartbeatStore) scanHeartbeat(row rowScanner) (*heartbeat.Heartbeat, er return &h, nil } -func (s *HeartbeatStore) scanHeartbeatRow(rows *sql.Rows) (*heartbeat.Heartbeat, error) { - return s.scanHeartbeat(rows) -} - func scanPingRow(row rowScanner) (*heartbeat.HeartbeatPing, error) { var p heartbeat.HeartbeatPing var exitCode sql.NullInt64 @@ -525,7 +469,7 @@ func scanPingRow(row rowScanner) (*heartbeat.HeartbeatPing, error) { return &p, nil } -func scanExecutionRow(row rowScanner) (*heartbeat.HeartbeatExecution, error) { +func scanExecution(row rowScanner) (*heartbeat.HeartbeatExecution, error) { var e heartbeat.HeartbeatExecution var startedAt, completedAt, durationMs, exitCode sql.NullInt64 var payload sql.NullString @@ -561,45 +505,6 @@ func scanExecutionRow(row rowScanner) (*heartbeat.HeartbeatExecution, error) { return &e, nil } -func scanExecutionSingle(row *sql.Row) (*heartbeat.HeartbeatExecution, error) { - var e heartbeat.HeartbeatExecution - var startedAt, completedAt, durationMs, exitCode sql.NullInt64 - var payload sql.NullString - - err := row.Scan( - &e.ID, &e.HeartbeatID, &startedAt, &completedAt, - &durationMs, &exitCode, &e.Outcome, &payload, - ) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, nil - } - return nil, fmt.Errorf("scan execution: %w", err) - } - - if startedAt.Valid { - t := time.Unix(startedAt.Int64, 0) - e.StartedAt = &t - } - if completedAt.Valid { - t := time.Unix(completedAt.Int64, 0) - e.CompletedAt = &t - } - if durationMs.Valid { - v := durationMs.Int64 - e.DurationMs = &v - } - if exitCode.Valid { - v := int(exitCode.Int64) - e.ExitCode = &v - } - if payload.Valid { - e.Payload = &payload.String - } - - return &e, nil -} - // GetHeartbeatByPingToken returns the heartbeat answering to a ping token, or nil when none does. func (s *HeartbeatStore) GetHeartbeatByPingToken(ctx context.Context, token string) (*heartbeat.Heartbeat, error) { return s.scanHeartbeat(s.db.QueryRowContext(ctx, diff --git a/internal/store/helpers.go b/internal/store/helpers.go index 49ed3b19..a05e176a 100644 --- a/internal/store/helpers.go +++ b/internal/store/helpers.go @@ -11,3 +11,8 @@ func NullableString(s string) interface{} { } return s } + +// rowScanner is satisfied by both *sql.Row and *sql.Rows. +type rowScanner interface { + Scan(dest ...any) error +} diff --git a/internal/store/paging.go b/internal/store/paging.go new file mode 100644 index 00000000..8dbedcd2 --- /dev/null +++ b/internal/store/paging.go @@ -0,0 +1,56 @@ +// Copyright 2026 Benjamin Touchard (Kolapsis) +// SPDX-License-Identifier: Apache-2.0 + +package store + +import ( + "context" + "fmt" +) + +const maxPageSize = 500 + +// pageQuery is one page of a list: countSQL and selectSQL take the same args, selectSQL has its ORDER BY but no LIMIT. +type pageQuery struct { + what string + countSQL string + selectSQL string + args []any + limit int + offset int + defaultLimit int +} + +// listPage returns the rows of one page and the total number of rows matching q. +func listPage[T any](ctx context.Context, db *Reader, q pageQuery, scan func(rowScanner) (T, error)) ([]T, int, error) { + var total int + if err := db.QueryRowContext(ctx, q.countSQL, q.args...).Scan(&total); err != nil { + return nil, 0, fmt.Errorf("count %s: %w", q.what, err) + } + + limit := q.limit + if limit <= 0 { + limit = q.defaultLimit + } + limit = min(limit, maxPageSize) + query := q.selectSQL + fmt.Sprintf(` LIMIT %d`, limit) + if q.offset > 0 { + query += fmt.Sprintf(` OFFSET %d`, q.offset) // #nosec G202 -- integer formatting of an int option. + } + + rows, err := db.QueryContext(ctx, query, q.args...) + if err != nil { + return nil, 0, fmt.Errorf("list %s: %w", q.what, err) + } + defer func() { _ = rows.Close() }() + + var results []T + for rows.Next() { + r, err := scan(rows) + if err != nil { + return nil, 0, err + } + results = append(results, r) + } + return results, total, rows.Err() +} diff --git a/internal/store/resources.go b/internal/store/resources.go index d2330475..fca47f88 100644 --- a/internal/store/resources.go +++ b/internal/store/resources.go @@ -191,12 +191,7 @@ func (s *ResourceStore) deleteSnapshotsBefore(ctx context.Context, before time.T return deleteRowsBefore(ctx, s.writer, o, "resource_snapshots", "timestamp", before) } -// rowScanner is implemented by both *sql.Row and *sql.Rows. -type resourceRowScanner interface { - Scan(dest ...interface{}) error -} - -func scanSnapshot(row resourceRowScanner) (*resource.ResourceSnapshot, error) { +func scanSnapshot(row rowScanner) (*resource.ResourceSnapshot, error) { var s resource.ResourceSnapshot var ts int64 err := row.Scan(&s.ID, &s.ContainerID, &s.AgentID, &s.CPUPercent, &s.MemUsed, &s.MemLimit, @@ -223,7 +218,7 @@ func collectSnapshots(rows *sql.Rows) ([]*resource.ResourceSnapshot, error) { return result, rows.Err() } -func scanAlertConfig(row resourceRowScanner) (*resource.ResourceAlertConfig, error) { +func scanAlertConfig(row rowScanner) (*resource.ResourceAlertConfig, error) { var cfg resource.ResourceAlertConfig var enabled int var alertState string diff --git a/internal/store/retention.go b/internal/store/retention.go index dec941cb..af2391b9 100644 --- a/internal/store/retention.go +++ b/internal/store/retention.go @@ -153,6 +153,16 @@ func (p *retentionPass) add(deleted int64, truncated bool) { p.truncated = p.truncated || truncated } +// record adds one purge to the pass and logs its outcome. +func (p *retentionPass) record(logger *slog.Logger, what string, deleted int64, truncated bool, err error) { + p.add(deleted, truncated) + if err != nil { + logger.Error("retention cleanup: "+what, "error", err) + } else if deleted > 0 { + logger.Info("retention cleanup: deleted "+what, "count", deleted) + } +} + // StartRetentionCleanupWithOpts starts retention cleanup with all store types. // The first pass runs immediately: an instance that restarts every few hours // would otherwise never reach its first cleanup. @@ -239,23 +249,13 @@ func runCleanup(ctx context.Context, now time.Time, store *ContainerStore, uptim if rolledUp(ctx, now, logger, uptime, "containers", cfg.Transitions, (*UptimeDailyStore).rollupContainers) { cutoff := now.Add(-cfg.Transitions) deleted, truncated, err := store.deleteTransitionsBefore(ctx, cutoff, cfg.batchOpts()) - pass.add(deleted, truncated) - if err != nil { - logger.Error("retention cleanup: transitions", "error", err) - } else if deleted > 0 { - logger.Info("retention cleanup: deleted transitions", "count", deleted) - } + pass.record(logger, "transitions", deleted, truncated, err) } // Clean old archived containers archiveCutoff := now.Add(-cfg.Archived) archivedDeleted, err := store.DeleteArchivedContainersBefore(ctx, archiveCutoff) - pass.add(archivedDeleted, false) - if err != nil { - logger.Error("retention cleanup: archived containers", "error", err) - } else if archivedDeleted > 0 { - logger.Info("retention cleanup: deleted archived containers", "count", archivedDeleted) - } + pass.record(logger, "archived containers", archivedDeleted, false, err) } func runHeartbeatCleanup(ctx context.Context, now time.Time, store *HeartbeatStore, uptime *UptimeDailyStore, logger *slog.Logger, cfg RetentionConfig, pass *retentionPass) { @@ -263,71 +263,36 @@ func runHeartbeatCleanup(ctx context.Context, now time.Time, store *HeartbeatSto if rolledUp(ctx, now, logger, uptime, "heartbeats", cfg.HeartbeatPings, (*UptimeDailyStore).rollupHeartbeats) { pingCutoff := now.Add(-cfg.HeartbeatPings) deleted, truncated, err := store.deletePingsBefore(ctx, pingCutoff, cfg.batchOpts()) - pass.add(deleted, truncated) - if err != nil { - logger.Error("retention cleanup: heartbeat pings", "error", err) - } else if deleted > 0 { - logger.Info("retention cleanup: deleted heartbeat pings", "count", deleted) - } + pass.record(logger, "heartbeat pings", deleted, truncated, err) pausesDeleted, pausesTruncated, err := store.deletePausesBefore(ctx, pingCutoff, cfg.batchOpts()) - pass.add(pausesDeleted, pausesTruncated) - if err != nil { - logger.Error("retention cleanup: heartbeat pauses", "error", err) - } else if pausesDeleted > 0 { - logger.Info("retention cleanup: deleted heartbeat pauses", "count", pausesDeleted) - } + pass.record(logger, "heartbeat pauses", pausesDeleted, pausesTruncated, err) } // Clean old heartbeat executions execCutoff := now.Add(-cfg.HeartbeatExecs) execDeleted, execTruncated, err := store.deleteExecutionsBefore(ctx, execCutoff, cfg.batchOpts()) - pass.add(execDeleted, execTruncated) - if err != nil { - logger.Error("retention cleanup: heartbeat executions", "error", err) - } else if execDeleted > 0 { - logger.Info("retention cleanup: deleted heartbeat executions", "count", execDeleted) - } + pass.record(logger, "heartbeat executions", execDeleted, execTruncated, err) } func runCertificateCleanup(ctx context.Context, now time.Time, store *CertificateStore, logger *slog.Logger, cfg RetentionConfig, pass *retentionPass) { cutoff := now.Add(-cfg.CertCheckResults) deleted, truncated, err := store.deleteCheckResultsBefore(ctx, cutoff, cfg.batchOpts()) - pass.add(deleted, truncated) - if err != nil { - logger.Error("retention cleanup: cert check results", "error", err) - } else if deleted > 0 { - logger.Info("retention cleanup: deleted cert check results", "count", deleted) - } + pass.record(logger, "cert check results", deleted, truncated, err) } func runResourceCleanup(ctx context.Context, now time.Time, store *ResourceStore, logger *slog.Logger, cfg RetentionConfig, pass *retentionPass) { cutoff := now.Add(-cfg.Snapshots) deleted, truncated, err := store.deleteSnapshotsBefore(ctx, cutoff, cfg.batchOpts()) - pass.add(deleted, truncated) - if err != nil { - logger.Error("retention cleanup: resource snapshots", "error", err) - } else if deleted > 0 { - logger.Info("retention cleanup: deleted resource snapshots", "count", deleted) - } + pass.record(logger, "resource snapshots", deleted, truncated, err) hourlyCutoff := now.Add(-cfg.Hourly) hourlyDeleted, hourlyTruncated, err := store.deleteHourlyBefore(ctx, hourlyCutoff, cfg.batchOpts()) - pass.add(hourlyDeleted, hourlyTruncated) - if err != nil { - logger.Error("retention cleanup: resource hourly", "error", err) - } else if hourlyDeleted > 0 { - logger.Info("retention cleanup: deleted resource hourly", "count", hourlyDeleted) - } + pass.record(logger, "resource hourly", hourlyDeleted, hourlyTruncated, err) dailyCutoff := now.Add(-cfg.Daily) dailyDeleted, dailyTruncated, err := store.deleteDailyBefore(ctx, dailyCutoff, cfg.batchOpts()) - pass.add(dailyDeleted, dailyTruncated) - if err != nil { - logger.Error("retention cleanup: resource daily", "error", err) - } else if dailyDeleted > 0 { - logger.Info("retention cleanup: deleted resource daily", "count", dailyDeleted) - } + pass.record(logger, "resource daily", dailyDeleted, dailyTruncated, err) } func runEndpointCleanup(ctx context.Context, now time.Time, store *EndpointStore, uptime *UptimeDailyStore, logger *slog.Logger, cfg RetentionConfig, pass *retentionPass) { @@ -335,23 +300,13 @@ func runEndpointCleanup(ctx context.Context, now time.Time, store *EndpointStore if rolledUp(ctx, now, logger, uptime, "endpoints", cfg.CheckResults, (*UptimeDailyStore).rollupEndpoints) { cutoff := now.Add(-cfg.CheckResults) deleted, truncated, err := store.deleteCheckResultsBefore(ctx, cutoff, cfg.batchOpts()) - pass.add(deleted, truncated) - if err != nil { - logger.Error("retention cleanup: check results", "error", err) - } else if deleted > 0 { - logger.Info("retention cleanup: deleted check results", "count", deleted) - } + pass.record(logger, "check results", deleted, truncated, err) } // Clean inactive endpoints inactiveCutoff := now.Add(-cfg.InactiveEndpoints) epDeleted, err := store.DeleteInactiveEndpointsBefore(ctx, inactiveCutoff) - pass.add(epDeleted, false) - if err != nil { - logger.Error("retention cleanup: inactive endpoints", "error", err) - } else if epDeleted > 0 { - logger.Info("retention cleanup: deleted inactive endpoints", "count", epDeleted) - } + pass.record(logger, "inactive endpoints", epDeleted, false, err) } // rolledUp writes the daily uptime aggregates of one monitor kind and reports @@ -373,12 +328,7 @@ func runUptimeDailyCleanup(ctx context.Context, now time.Time, uptime *UptimeDai cutoff := now.Add(-cfg.UptimeDaily) for _, t := range []uptimeTable{endpointUptimeTable, heartbeatUptimeTable, containerUptimeTable} { deleted, truncated, err := uptime.deleteBefore(ctx, t, cutoff, cfg.batchOpts()) - pass.add(deleted, truncated) - if err != nil { - logger.Error("retention cleanup: "+t.name, "error", err) - } else if deleted > 0 { - logger.Info("retention cleanup: deleted "+t.name, "count", deleted) - } + pass.record(logger, t.name, deleted, truncated, err) } } diff --git a/internal/store/transform_cert_sni.go b/internal/store/transform_cert_sni.go index 238650c5..901963dc 100644 --- a/internal/store/transform_cert_sni.go +++ b/internal/store/transform_cert_sni.go @@ -6,10 +6,7 @@ package store import ( "context" "database/sql" - "errors" - "fmt" "log/slog" - "strings" ) // certSNIConstraint is the SNI-aware identity constraint. It must stay @@ -24,62 +21,13 @@ const certSNIConstraint = "UNIQUE(agent_id, hostname, port, server_name)" // installs and databases converted by this release get the new constraint // straight from uuid_schema.sql and skip the rebuild. func rebuildCertMonitorsForSNI(ctx context.Context, db *sql.DB, logger *slog.Logger) error { - var ddl string - err := db.QueryRowContext(ctx, - `SELECT sql FROM sqlite_master WHERE type='table' AND name='cert_monitors'`).Scan(&ddl) - if errors.Is(err, sql.ErrNoRows) { - return nil - } - if err != nil { - return fmt.Errorf("cert sni rebuild: probe ddl: %w", err) - } - if strings.Contains(ddl, certSNIConstraint) { - return nil - } - - conn, err := db.Conn(ctx) - if err != nil { - return fmt.Errorf("cert sni rebuild: acquire conn: %w", err) - } - defer func() { _ = conn.Close() }() - - if _, err := conn.ExecContext(ctx, "PRAGMA foreign_keys=OFF"); err != nil { - return fmt.Errorf("cert sni rebuild: fk off: %w", err) - } - - logger.Info("cert monitor SNI rebuild starting") - - if err := runCertSNIRebuild(ctx, conn); err != nil { - return err - } - - if err := foreignKeyCheck(ctx, conn); err != nil { - return err - } - if _, err := conn.ExecContext(ctx, "PRAGMA foreign_keys=ON"); err != nil { - return fmt.Errorf("cert sni rebuild: fk on: %w", err) - } - - logger.Info("cert monitor SNI rebuild complete") - return nil -} - -// runCertSNIRebuild performs the table rebuild inside one transaction. -func runCertSNIRebuild(ctx context.Context, conn *sql.Conn) error { - tx, err := conn.BeginTx(ctx, nil) - if err != nil { - return fmt.Errorf("cert sni rebuild: begin: %w", err) - } - committed := false - defer func() { - if !committed { - _ = tx.Rollback() - } - }() - - stmts := []stmt{ - // Keep this CREATE TABLE byte-identical to uuid_schema.sql. - {"create table", `CREATE TABLE cert_monitors_new ( + return rebuildTable(ctx, db, logger, tableRebuild{ + name: "cert sni rebuild", + table: "cert_monitors", + marker: certSNIConstraint, + stmts: []stmt{ + // Keep this CREATE TABLE byte-identical to uuid_schema.sql. + {"create table", `CREATE TABLE cert_monitors_new ( id TEXT PRIMARY KEY NOT NULL, -- uid.CertMonitor(agent,host,port[,server_name]) or minted (standalone) agent_id TEXT NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000' REFERENCES agents(id) ON DELETE CASCADE, hostname TEXT NOT NULL, @@ -98,32 +46,22 @@ func runCertSNIRebuild(ctx context.Context, conn *sql.Conn) error { server_name TEXT NOT NULL DEFAULT '', -- SNI; '' = validate against hostname ` + certSNIConstraint + ` )`}, - {"copy rows", `INSERT INTO cert_monitors_new - (id, agent_id, hostname, port, source, endpoint_id, status, check_interval_seconds, - warning_thresholds_json, last_alerted_threshold, last_check_at, next_check_at, last_error, - created_at, external_id, server_name) - SELECT id, agent_id, hostname, port, source, endpoint_id, status, check_interval_seconds, - warning_thresholds_json, last_alerted_threshold, last_check_at, next_check_at, last_error, - created_at, external_id, server_name - FROM cert_monitors`}, - {"drop old table", `DROP TABLE cert_monitors`}, - {"rename", `ALTER TABLE cert_monitors_new RENAME TO cert_monitors`}, - // DROP TABLE removed the old table's indexes — recreate them all. - {"index endpoint", `CREATE INDEX idx_cert_monitor_endpoint ON cert_monitors(endpoint_id) WHERE endpoint_id IS NOT NULL`}, - {"index status", `CREATE INDEX idx_cert_monitor_status ON cert_monitors(status)`}, - {"index next_check", `CREATE INDEX idx_cert_monitor_next_check ON cert_monitors(next_check_at) WHERE source IN ('standalone','label')`}, - {"index external_id", `CREATE INDEX idx_cert_monitor_external_id ON cert_monitors(external_id) WHERE external_id != ''`}, - {"index agent_id", `CREATE INDEX idx_cert_monitors_agent_id ON cert_monitors(agent_id)`}, - } - for _, s := range stmts { - if _, err := tx.ExecContext(ctx, s.sql); err != nil { - return fmt.Errorf("cert sni rebuild: %s: %w", s.desc, err) - } - } - - if err := tx.Commit(); err != nil { - return fmt.Errorf("cert sni rebuild: commit: %w", err) - } - committed = true - return nil + {"copy rows", `INSERT INTO cert_monitors_new + (id, agent_id, hostname, port, source, endpoint_id, status, check_interval_seconds, + warning_thresholds_json, last_alerted_threshold, last_check_at, next_check_at, last_error, + created_at, external_id, server_name) + SELECT id, agent_id, hostname, port, source, endpoint_id, status, check_interval_seconds, + warning_thresholds_json, last_alerted_threshold, last_check_at, next_check_at, last_error, + created_at, external_id, server_name + FROM cert_monitors`}, + {"drop old table", `DROP TABLE cert_monitors`}, + {"rename", `ALTER TABLE cert_monitors_new RENAME TO cert_monitors`}, + // DROP TABLE removed the old table's indexes — recreate them all. + {"index endpoint", `CREATE INDEX idx_cert_monitor_endpoint ON cert_monitors(endpoint_id) WHERE endpoint_id IS NOT NULL`}, + {"index status", `CREATE INDEX idx_cert_monitor_status ON cert_monitors(status)`}, + {"index next_check", `CREATE INDEX idx_cert_monitor_next_check ON cert_monitors(next_check_at) WHERE source IN ('standalone','label')`}, + {"index external_id", `CREATE INDEX idx_cert_monitor_external_id ON cert_monitors(external_id) WHERE external_id != ''`}, + {"index agent_id", `CREATE INDEX idx_cert_monitors_agent_id ON cert_monitors(agent_id)`}, + }, + }) } diff --git a/internal/store/transform_endpoint_degraded.go b/internal/store/transform_endpoint_degraded.go index 4443867e..ddccb934 100644 --- a/internal/store/transform_endpoint_degraded.go +++ b/internal/store/transform_endpoint_degraded.go @@ -6,10 +6,7 @@ package store import ( "context" "database/sql" - "errors" - "fmt" "log/slog" - "strings" ) // endpointStatusConstraint is the status CHECK allowing the degraded state. It @@ -27,62 +24,13 @@ const endpointStatusConstraint = "CHECK(status IN ('up','down','degraded','unkno // migrations execute against the legacy integer schema, where the endpoints // table does not have agent_id yet, so one SQL file cannot serve both shapes. func rebuildEndpointsForDegraded(ctx context.Context, db *sql.DB, logger *slog.Logger) error { - var ddl string - err := db.QueryRowContext(ctx, - `SELECT sql FROM sqlite_master WHERE type='table' AND name='endpoints'`).Scan(&ddl) - if errors.Is(err, sql.ErrNoRows) { - return nil - } - if err != nil { - return fmt.Errorf("endpoint degraded rebuild: probe ddl: %w", err) - } - if strings.Contains(ddl, endpointStatusConstraint) { - return nil - } - - conn, err := db.Conn(ctx) - if err != nil { - return fmt.Errorf("endpoint degraded rebuild: acquire conn: %w", err) - } - defer func() { _ = conn.Close() }() - - if _, err := conn.ExecContext(ctx, "PRAGMA foreign_keys=OFF"); err != nil { - return fmt.Errorf("endpoint degraded rebuild: fk off: %w", err) - } - - logger.Info("endpoint degraded-status rebuild starting") - - if err := runEndpointDegradedRebuild(ctx, conn); err != nil { - return err - } - - if err := foreignKeyCheck(ctx, conn); err != nil { - return err - } - if _, err := conn.ExecContext(ctx, "PRAGMA foreign_keys=ON"); err != nil { - return fmt.Errorf("endpoint degraded rebuild: fk on: %w", err) - } - - logger.Info("endpoint degraded-status rebuild complete") - return nil -} - -// runEndpointDegradedRebuild performs the table rebuild inside one transaction. -func runEndpointDegradedRebuild(ctx context.Context, conn *sql.Conn) error { - tx, err := conn.BeginTx(ctx, nil) - if err != nil { - return fmt.Errorf("endpoint degraded rebuild: begin: %w", err) - } - committed := false - defer func() { - if !committed { - _ = tx.Rollback() - } - }() - - stmts := []stmt{ - // Keep this CREATE TABLE byte-identical to uuid_schema.sql. - {"create table", `CREATE TABLE endpoints_new ( + return rebuildTable(ctx, db, logger, tableRebuild{ + name: "endpoint degraded rebuild", + table: "endpoints", + marker: endpointStatusConstraint, + stmts: []stmt{ + // Keep this CREATE TABLE byte-identical to uuid_schema.sql. + {"create table", `CREATE TABLE endpoints_new ( id TEXT PRIMARY KEY NOT NULL, -- uid.EndpointLabel(agent,container,label) or minted (standalone) agent_id TEXT NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000' REFERENCES agents(id) ON DELETE CASCADE, container_name TEXT NOT NULL, @@ -106,34 +54,24 @@ func runEndpointDegradedRebuild(ctx context.Context, conn *sql.Conn) error { name TEXT NOT NULL DEFAULT '', UNIQUE(agent_id, container_name, label_key) )`}, - {"copy rows", `INSERT INTO endpoints_new - (id, agent_id, container_name, label_key, external_id, endpoint_type, target, - status, alert_state, consecutive_failures, consecutive_successes, last_check_at, - last_response_time_ms, last_http_status, last_error, config_json, active, - first_seen_at, last_seen_at, source, name) - SELECT id, agent_id, container_name, label_key, external_id, endpoint_type, target, - status, alert_state, consecutive_failures, consecutive_successes, last_check_at, - last_response_time_ms, last_http_status, last_error, config_json, active, - first_seen_at, last_seen_at, source, name - FROM endpoints`}, - {"drop old table", `DROP TABLE endpoints`}, - {"rename", `ALTER TABLE endpoints_new RENAME TO endpoints`}, - // DROP TABLE removed the old table's indexes — recreate them all. - {"index external_id", `CREATE INDEX idx_endpoint_external_id ON endpoints(external_id)`}, - {"index status", `CREATE INDEX idx_endpoint_status ON endpoints(status) WHERE active=1`}, - {"index active", `CREATE INDEX idx_endpoint_active ON endpoints(active, last_seen_at DESC)`}, - {"index source", `CREATE INDEX idx_endpoint_source ON endpoints(source) WHERE active=1`}, - {"index agent_id", `CREATE INDEX idx_endpoints_agent_id ON endpoints(agent_id)`}, - } - for _, s := range stmts { - if _, err := tx.ExecContext(ctx, s.sql); err != nil { - return fmt.Errorf("endpoint degraded rebuild: %s: %w", s.desc, err) - } - } - - if err := tx.Commit(); err != nil { - return fmt.Errorf("endpoint degraded rebuild: commit: %w", err) - } - committed = true - return nil + {"copy rows", `INSERT INTO endpoints_new + (id, agent_id, container_name, label_key, external_id, endpoint_type, target, + status, alert_state, consecutive_failures, consecutive_successes, last_check_at, + last_response_time_ms, last_http_status, last_error, config_json, active, + first_seen_at, last_seen_at, source, name) + SELECT id, agent_id, container_name, label_key, external_id, endpoint_type, target, + status, alert_state, consecutive_failures, consecutive_successes, last_check_at, + last_response_time_ms, last_http_status, last_error, config_json, active, + first_seen_at, last_seen_at, source, name + FROM endpoints`}, + {"drop old table", `DROP TABLE endpoints`}, + {"rename", `ALTER TABLE endpoints_new RENAME TO endpoints`}, + // DROP TABLE removed the old table's indexes — recreate them all. + {"index external_id", `CREATE INDEX idx_endpoint_external_id ON endpoints(external_id)`}, + {"index status", `CREATE INDEX idx_endpoint_status ON endpoints(status) WHERE active=1`}, + {"index active", `CREATE INDEX idx_endpoint_active ON endpoints(active, last_seen_at DESC)`}, + {"index source", `CREATE INDEX idx_endpoint_source ON endpoints(source) WHERE active=1`}, + {"index agent_id", `CREATE INDEX idx_endpoints_agent_id ON endpoints(agent_id)`}, + }, + }) } diff --git a/internal/store/transform_enrollment_token_hash.go b/internal/store/transform_enrollment_token_hash.go index 3c819f44..37e1cfc0 100644 --- a/internal/store/transform_enrollment_token_hash.go +++ b/internal/store/transform_enrollment_token_hash.go @@ -6,10 +6,8 @@ package store import ( "context" "database/sql" - "errors" "fmt" "log/slog" - "strings" "github.com/kolapsis/maintenant/internal/agent" "github.com/mattn/go-sqlite3" @@ -39,71 +37,43 @@ const enrollmentTokenHashColumn = "token_hash TEXT NOT NULL UNIQUE" // #nosec // reason as the other rebuilds: migrations execute against the legacy integer // schema, where this table does not exist at all. func rebuildEnrollmentTokensForHashing(ctx context.Context, db *sql.DB, logger *slog.Logger) error { - var ddl string - err := db.QueryRowContext(ctx, - `SELECT sql FROM sqlite_master WHERE type='table' AND name='enrollment_tokens'`).Scan(&ddl) - if errors.Is(err, sql.ErrNoRows) { - return nil - } - if err != nil { - return fmt.Errorf("enrollment token hash rebuild: probe ddl: %w", err) - } - if strings.Contains(ddl, enrollmentTokenHashColumn) { - return nil - } - - conn, err := db.Conn(ctx) - if err != nil { - return fmt.Errorf("enrollment token hash rebuild: acquire conn: %w", err) - } - defer func() { _ = conn.Close() }() - - // The copy hashes set-based rather than row-by-row in Go, so the whole - // rewrite stays inside the one transaction below. - if err := registerHashFunc(ctx, conn); err != nil { - return fmt.Errorf("enrollment token hash rebuild: register func: %w", err) - } - - if _, err := conn.ExecContext(ctx, "PRAGMA foreign_keys=OFF"); err != nil { - return fmt.Errorf("enrollment token hash rebuild: fk off: %w", err) - } - - // Dropping the old table marks its pages free without overwriting them, so - // the cleartext could stay legible in the file's slack space. secure_delete - // is SQLite's documented way to zero freed content instead. - // - // Precaution, not a guarantee: it cannot reach a copy of the file taken - // before the upgrade, nor blocks a copy-on-write filesystem or an SSD's - // wear-levelling still holds underneath. Revoking an outstanding token is - // the only thing that fully closes it — their 7-day TTL does the rest. - if _, err := conn.ExecContext(ctx, "PRAGMA secure_delete=ON"); err != nil { - return fmt.Errorf("enrollment token hash rebuild: secure_delete on: %w", err) - } - - logger.Info("enrollment token hashing rebuild starting") - - if err := runEnrollmentTokenHashRebuild(ctx, conn); err != nil { - return err - } - - if err := foreignKeyCheck(ctx, conn); err != nil { - return err - } - // Return the freed pages to the file rather than leaving them on the - // freelist. The database is auto_vacuum=incremental, so this is bounded - // work rather than a full VACUUM rewrite. - if _, err := conn.ExecContext(ctx, "PRAGMA incremental_vacuum"); err != nil { - return fmt.Errorf("enrollment token hash rebuild: incremental_vacuum: %w", err) - } - if _, err := conn.ExecContext(ctx, "PRAGMA secure_delete=OFF"); err != nil { - return fmt.Errorf("enrollment token hash rebuild: secure_delete off: %w", err) - } - if _, err := conn.ExecContext(ctx, "PRAGMA foreign_keys=ON"); err != nil { - return fmt.Errorf("enrollment token hash rebuild: fk on: %w", err) - } - - logger.Info("enrollment token hashing rebuild complete") - return nil + return rebuildTable(ctx, db, logger, tableRebuild{ + name: "enrollment token hash rebuild", + table: "enrollment_tokens", + marker: enrollmentTokenHashColumn, + stmts: enrollmentTokenHashStmts, + before: func(ctx context.Context, conn *sql.Conn) error { + // The copy hashes set-based rather than row-by-row in Go, so the whole + // rewrite stays inside the one transaction. + if err := registerHashFunc(ctx, conn); err != nil { + return fmt.Errorf("register func: %w", err) + } + // Dropping the old table marks its pages free without overwriting them, so + // the cleartext could stay legible in the file's slack space. secure_delete + // is SQLite's documented way to zero freed content instead. + // + // Precaution, not a guarantee: it cannot reach a copy of the file taken + // before the upgrade, nor blocks a copy-on-write filesystem or an SSD's + // wear-levelling still holds underneath. Revoking an outstanding token is + // the only thing that fully closes it — their 7-day TTL does the rest. + if _, err := conn.ExecContext(ctx, "PRAGMA secure_delete=ON"); err != nil { + return fmt.Errorf("secure_delete on: %w", err) + } + return nil + }, + after: func(ctx context.Context, conn *sql.Conn) error { + // Return the freed pages to the file rather than leaving them on the + // freelist. The database is auto_vacuum=incremental, so this is bounded + // work rather than a full VACUUM rewrite. + if _, err := conn.ExecContext(ctx, "PRAGMA incremental_vacuum"); err != nil { + return fmt.Errorf("incremental_vacuum: %w", err) + } + if _, err := conn.ExecContext(ctx, "PRAGMA secure_delete=OFF"); err != nil { + return fmt.Errorf("secure_delete off: %w", err) + } + return nil + }, + }) } // registerHashFunc exposes the token hash and prefix helpers to SQL so the copy @@ -121,22 +91,9 @@ func registerHashFunc(ctx context.Context, conn *sql.Conn) error { }) } -// runEnrollmentTokenHashRebuild performs the table rebuild in one transaction. -func runEnrollmentTokenHashRebuild(ctx context.Context, conn *sql.Conn) error { - tx, err := conn.BeginTx(ctx, nil) - if err != nil { - return fmt.Errorf("enrollment token hash rebuild: begin: %w", err) - } - committed := false - defer func() { - if !committed { - _ = tx.Rollback() - } - }() - - stmts := []stmt{ - // Keep this CREATE TABLE byte-identical to uuid_schema.sql. - {"create table", `CREATE TABLE enrollment_tokens_new ( +var enrollmentTokenHashStmts = []stmt{ + // Keep this CREATE TABLE byte-identical to uuid_schema.sql. + {"create table", `CREATE TABLE enrollment_tokens_new ( id TEXT PRIMARY KEY NOT NULL, -- hex(sha256(token))[:16] ` + enrollmentTokenHashColumn + `, -- hex(sha256(token)); the cleartext is never stored token_prefix TEXT NOT NULL DEFAULT '', -- leading non-secret chars, display only @@ -145,27 +102,15 @@ func runEnrollmentTokenHashRebuild(ctx context.Context, conn *sql.Conn) error { consumed_at BIGINT, consumed_by_agent_id TEXT -- audit only, no FK (agent may not exist yet) )`}, - // A duplicate hash cannot happen (the source column is UNIQUE and the - // hash is injective over it), so the UNIQUE above stays satisfiable. - {"copy rows", `INSERT INTO enrollment_tokens_new - (id, token_hash, token_prefix, created_at, expires_at, consumed_at, consumed_by_agent_id) - SELECT id, mnt_token_hash(token), mnt_token_prefix(token), - created_at, expires_at, consumed_at, consumed_by_agent_id - FROM enrollment_tokens`}, - {"drop old table", `DROP TABLE enrollment_tokens`}, - {"rename", `ALTER TABLE enrollment_tokens_new RENAME TO enrollment_tokens`}, - // DROP TABLE removed the old table's index — recreate it. - {"index expires_at", `CREATE INDEX idx_enrollment_tokens_expires_at ON enrollment_tokens(expires_at)`}, - } - for _, s := range stmts { - if _, err := tx.ExecContext(ctx, s.sql); err != nil { - return fmt.Errorf("enrollment token hash rebuild: %s: %w", s.desc, err) - } - } - - if err := tx.Commit(); err != nil { - return fmt.Errorf("enrollment token hash rebuild: commit: %w", err) - } - committed = true - return nil + // A duplicate hash cannot happen (the source column is UNIQUE and the + // hash is injective over it), so the UNIQUE above stays satisfiable. + {"copy rows", `INSERT INTO enrollment_tokens_new + (id, token_hash, token_prefix, created_at, expires_at, consumed_at, consumed_by_agent_id) + SELECT id, mnt_token_hash(token), mnt_token_prefix(token), + created_at, expires_at, consumed_at, consumed_by_agent_id + FROM enrollment_tokens`}, + {"drop old table", `DROP TABLE enrollment_tokens`}, + {"rename", `ALTER TABLE enrollment_tokens_new RENAME TO enrollment_tokens`}, + // DROP TABLE removed the old table's index — recreate it. + {"index expires_at", `CREATE INDEX idx_enrollment_tokens_expires_at ON enrollment_tokens(expires_at)`}, } diff --git a/internal/store/transform_escalation_delivery_status.go b/internal/store/transform_escalation_delivery_status.go index fe3c5141..7dff6edc 100644 --- a/internal/store/transform_escalation_delivery_status.go +++ b/internal/store/transform_escalation_delivery_status.go @@ -6,10 +6,7 @@ package store import ( "context" "database/sql" - "errors" - "fmt" "log/slog" - "strings" ) // escalationDeliveryStatusConstraint must stay byte-identical to the escalation_deliveries DDL in uuid_schema.sql: the rebuild guard matches on it. @@ -17,60 +14,12 @@ const escalationDeliveryStatusConstraint = "CHECK(status IN ('pending','sent','f // rebuildEscalationDeliveriesStatusCheck drops 'skipped_maintenance' from the delivery status CHECK on databases converted before migration 38, which PostgreSQL applies as an ALTER that SQLite cannot run. func rebuildEscalationDeliveriesStatusCheck(ctx context.Context, db *sql.DB, logger *slog.Logger) error { - var ddl string - err := db.QueryRowContext(ctx, - `SELECT sql FROM sqlite_master WHERE type='table' AND name='escalation_deliveries'`).Scan(&ddl) - if errors.Is(err, sql.ErrNoRows) { - return nil - } - if err != nil { - return fmt.Errorf("escalation delivery status rebuild: probe ddl: %w", err) - } - if strings.Contains(ddl, escalationDeliveryStatusConstraint) { - return nil - } - - conn, err := db.Conn(ctx) - if err != nil { - return fmt.Errorf("escalation delivery status rebuild: acquire conn: %w", err) - } - defer func() { _ = conn.Close() }() - - if _, err := conn.ExecContext(ctx, "PRAGMA foreign_keys=OFF"); err != nil { - return fmt.Errorf("escalation delivery status rebuild: fk off: %w", err) - } - - logger.Info("escalation delivery status rebuild starting") - - if err := runEscalationDeliveryStatusRebuild(ctx, conn); err != nil { - return err - } - - if err := foreignKeyCheck(ctx, conn); err != nil { - return err - } - if _, err := conn.ExecContext(ctx, "PRAGMA foreign_keys=ON"); err != nil { - return fmt.Errorf("escalation delivery status rebuild: fk on: %w", err) - } - - logger.Info("escalation delivery status rebuild complete") - return nil -} - -func runEscalationDeliveryStatusRebuild(ctx context.Context, conn *sql.Conn) error { - tx, err := conn.BeginTx(ctx, nil) - if err != nil { - return fmt.Errorf("escalation delivery status rebuild: begin: %w", err) - } - committed := false - defer func() { - if !committed { - _ = tx.Rollback() - } - }() - - stmts := []stmt{ - {"create table", `CREATE TABLE escalation_deliveries_new ( + return rebuildTable(ctx, db, logger, tableRebuild{ + name: "escalation delivery status rebuild", + table: "escalation_deliveries", + marker: escalationDeliveryStatusConstraint, + stmts: []stmt{ + {"create table", `CREATE TABLE escalation_deliveries_new ( id TEXT PRIMARY KEY NOT NULL, run_id TEXT NOT NULL REFERENCES escalation_runs(id) ON DELETE CASCADE, level_index INTEGER NOT NULL, @@ -80,25 +29,15 @@ func runEscalationDeliveryStatusRebuild(ctx context.Context, conn *sql.Conn) err attempt_started_at BIGINT NOT NULL DEFAULT 0, sent_at BIGINT )`}, - {"copy rows", `INSERT INTO escalation_deliveries_new - (id, run_id, level_index, channel_id, status, error, attempt_started_at, sent_at) - SELECT id, run_id, level_index, channel_id, status, error, attempt_started_at, sent_at - FROM escalation_deliveries`}, - {"drop old table", `DROP TABLE escalation_deliveries`}, - {"rename", `ALTER TABLE escalation_deliveries_new RENAME TO escalation_deliveries`}, - {"index run_level", `CREATE UNIQUE INDEX idx_escalation_deliveries_run_level ON escalation_deliveries(run_id, level_index, channel_id)`}, - {"index pending", `CREATE INDEX idx_escalation_deliveries_pending ON escalation_deliveries(status, attempt_started_at) WHERE status = 'pending'`}, - {"index run_id", `CREATE INDEX idx_escalation_deliveries_run_id ON escalation_deliveries(run_id)`}, - } - for _, s := range stmts { - if _, err := tx.ExecContext(ctx, s.sql); err != nil { - return fmt.Errorf("escalation delivery status rebuild: %s: %w", s.desc, err) - } - } - - if err := tx.Commit(); err != nil { - return fmt.Errorf("escalation delivery status rebuild: commit: %w", err) - } - committed = true - return nil + {"copy rows", `INSERT INTO escalation_deliveries_new + (id, run_id, level_index, channel_id, status, error, attempt_started_at, sent_at) + SELECT id, run_id, level_index, channel_id, status, error, attempt_started_at, sent_at + FROM escalation_deliveries`}, + {"drop old table", `DROP TABLE escalation_deliveries`}, + {"rename", `ALTER TABLE escalation_deliveries_new RENAME TO escalation_deliveries`}, + {"index run_level", `CREATE UNIQUE INDEX idx_escalation_deliveries_run_level ON escalation_deliveries(run_id, level_index, channel_id)`}, + {"index pending", `CREATE INDEX idx_escalation_deliveries_pending ON escalation_deliveries(status, attempt_started_at) WHERE status = 'pending'`}, + {"index run_id", `CREATE INDEX idx_escalation_deliveries_run_id ON escalation_deliveries(run_id)`}, + }, + }) } diff --git a/internal/store/transform_rebuild.go b/internal/store/transform_rebuild.go new file mode 100644 index 00000000..b98e6c46 --- /dev/null +++ b/internal/store/transform_rebuild.go @@ -0,0 +1,99 @@ +// Copyright 2026 Benjamin Touchard (Kolapsis) +// SPDX-License-Identifier: Apache-2.0 + +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "log/slog" + "strings" +) + +// tableRebuild is a one-time SQLite table rebuild, skipped once the stored CREATE TABLE contains marker. +type tableRebuild struct { + name string + table string + marker string + stmts []stmt + before func(ctx context.Context, conn *sql.Conn) error // runs before foreign keys are switched off + after func(ctx context.Context, conn *sql.Conn) error // runs after the foreign key check +} + +// rebuildTable runs r with foreign keys off, all statements in one transaction, then checks the foreign keys. +func rebuildTable(ctx context.Context, db *sql.DB, logger *slog.Logger, r tableRebuild) error { + var ddl string + err := db.QueryRowContext(ctx, + `SELECT sql FROM sqlite_master WHERE type='table' AND name=?`, r.table).Scan(&ddl) + if errors.Is(err, sql.ErrNoRows) { + return nil + } + if err != nil { + return fmt.Errorf("%s: probe ddl: %w", r.name, err) + } + if strings.Contains(ddl, r.marker) { + return nil + } + + conn, err := db.Conn(ctx) + if err != nil { + return fmt.Errorf("%s: acquire conn: %w", r.name, err) + } + defer func() { _ = conn.Close() }() + + if r.before != nil { + if err := r.before(ctx, conn); err != nil { + return fmt.Errorf("%s: %w", r.name, err) + } + } + if _, err := conn.ExecContext(ctx, "PRAGMA foreign_keys=OFF"); err != nil { + return fmt.Errorf("%s: fk off: %w", r.name, err) + } + + logger.Info(r.name + " starting") + + if err := execStmtsInTx(ctx, conn, r.name, r.stmts); err != nil { + return err + } + if err := foreignKeyCheck(ctx, conn); err != nil { + return err + } + if r.after != nil { + if err := r.after(ctx, conn); err != nil { + return fmt.Errorf("%s: %w", r.name, err) + } + } + if _, err := conn.ExecContext(ctx, "PRAGMA foreign_keys=ON"); err != nil { + return fmt.Errorf("%s: fk on: %w", r.name, err) + } + + logger.Info(r.name + " complete") + return nil +} + +func execStmtsInTx(ctx context.Context, conn *sql.Conn, name string, stmts []stmt) error { + tx, err := conn.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("%s: begin: %w", name, err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback() + } + }() + + for _, s := range stmts { + if _, err := tx.ExecContext(ctx, s.sql); err != nil { + return fmt.Errorf("%s: %s: %w", name, s.desc, err) + } + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("%s: commit: %w", name, err) + } + committed = true + return nil +} diff --git a/internal/store/triggers.go b/internal/store/triggers.go index d14af5fc..476d6e7b 100644 --- a/internal/store/triggers.go +++ b/internal/store/triggers.go @@ -94,7 +94,7 @@ func (s *TriggerStoreImpl) listTriggersWhere(ctx context.Context, where string) var triggers []*alert.AlertTrigger for rows.Next() { - t, err := scanTriggerRow(rows) + t, err := scanTrigger(rows) if err != nil { return nil, err } @@ -208,7 +208,7 @@ func (s *TriggerStoreImpl) ListTriggersForChannel(ctx context.Context, channelID var triggers []*alert.AlertTrigger for rows.Next() { - t, err := scanTriggerRow(rows) + t, err := scanTrigger(rows) if err != nil { return nil, err } @@ -230,7 +230,7 @@ func (s *TriggerStoreImpl) ListTriggersForChannel(ctx context.Context, channelID // --- Scan helpers --- -func scanTrigger(row *sql.Row) (*alert.AlertTrigger, error) { +func scanTrigger(row rowScanner) (*alert.AlertTrigger, error) { t := &alert.AlertTrigger{} var enabled, notifyOnResolve int var createdAt, updatedAt int64 @@ -245,19 +245,3 @@ func scanTrigger(row *sql.Row) (*alert.AlertTrigger, error) { t.UpdatedAt = time.Unix(updatedAt, 0) return t, nil } - -func scanTriggerRow(rows *sql.Rows) (*alert.AlertTrigger, error) { - t := &alert.AlertTrigger{} - var enabled, notifyOnResolve int - var createdAt, updatedAt int64 - err := rows.Scan(&t.ID, &t.Name, &t.FilterSeverities, &t.FilterSources, - &t.FilterScopes, &enabled, ¬ifyOnResolve, &createdAt, &updatedAt) - if err != nil { - return nil, err - } - t.Enabled = enabled == 1 - t.NotifyOnResolve = notifyOnResolve == 1 - t.CreatedAt = time.Unix(createdAt, 0) - t.UpdatedAt = time.Unix(updatedAt, 0) - return t, nil -} diff --git a/internal/store/updates.go b/internal/store/updates.go index 94fcf02e..fb23f8c7 100644 --- a/internal/store/updates.go +++ b/internal/store/updates.go @@ -765,11 +765,7 @@ func (s *UpdateStore) CleanupExpired(ctx context.Context, olderThan time.Time) ( // --- Row scanners --- -type updateRowScanner interface { - Scan(dest ...interface{}) error -} - -func scanScanRecord(row updateRowScanner) (*update.ScanRecord, error) { +func scanScanRecord(row rowScanner) (*update.ScanRecord, error) { var r update.ScanRecord var startedAt int64 var completedAt sql.NullInt64 @@ -790,7 +786,7 @@ func scanScanRecord(row updateRowScanner) (*update.ScanRecord, error) { return &r, nil } -func scanImageUpdate(row updateRowScanner) (*update.ImageUpdate, error) { +func scanImageUpdate(row rowScanner) (*update.ImageUpdate, error) { var u update.ImageUpdate var publishedAt sql.NullInt64 var detectedAt int64 @@ -861,7 +857,7 @@ func collectImageUpdates(rows *sql.Rows) ([]*update.ImageUpdate, error) { return result, rows.Err() } -func scanCVECacheEntry(row updateRowScanner) (*update.CVECacheEntry, error) { +func scanCVECacheEntry(row rowScanner) (*update.CVECacheEntry, error) { var e update.CVECacheEntry var fetchedAt, expiresAt int64 var cvssVector, summary, fixedIn, referencesJSON sql.NullString @@ -899,7 +895,7 @@ func scanCVECacheEntry(row updateRowScanner) (*update.CVECacheEntry, error) { return &e, nil } -func scanContainerCVE(row updateRowScanner) (*update.ContainerCVE, error) { +func scanContainerCVE(row rowScanner) (*update.ContainerCVE, error) { var c update.ContainerCVE var firstDetectedAt int64 var resolvedAt sql.NullInt64 @@ -946,7 +942,7 @@ func collectContainerCVEs(rows *sql.Rows) ([]*update.ContainerCVE, error) { return result, rows.Err() } -func scanVersionPin(row updateRowScanner) (*update.VersionPin, error) { +func scanVersionPin(row rowScanner) (*update.VersionPin, error) { var p update.VersionPin var pinnedAt int64 var reason sql.NullString diff --git a/internal/swarm/alerts.go b/internal/swarm/alerts.go index f3bf6683..53504015 100644 --- a/internal/swarm/alerts.go +++ b/internal/swarm/alerts.go @@ -42,11 +42,6 @@ func NewReplicaHealthChecker(logger *slog.Logger) *ReplicaHealthChecker { } } -// SetAlertDelay overrides the default alert delay for testing. -func (rhc *ReplicaHealthChecker) SetAlertDelay(d time.Duration) { - rhc.alertDelay = d -} - // SetEventCallback sets the SSE event callback. func (rhc *ReplicaHealthChecker) SetEventCallback(cb EventCallback) { rhc.callback = cb diff --git a/internal/swarm/alerts_test.go b/internal/swarm/alerts_test.go index 696638bd..1bad654c 100644 --- a/internal/swarm/alerts_test.go +++ b/internal/swarm/alerts_test.go @@ -41,7 +41,7 @@ func (s *alertSink) take() []alert.Event { func TestReplicaHealthChecker_IgnoredServiceRaisesNothing(t *testing.T) { sink := &alertSink{} rhc := NewReplicaHealthChecker(quietLogger()) - rhc.SetAlertDelay(0) + rhc.alertDelay = 0 rhc.SetAlertCallback(sink.record) svc := underReplicated("svc1", "prod_web", ignoreLabels) @@ -53,7 +53,7 @@ func TestReplicaHealthChecker_IgnoredServiceRaisesNothing(t *testing.T) { func TestReplicaHealthChecker_ServiceIgnoredWhileAlertedResolves(t *testing.T) { sink := &alertSink{} rhc := NewReplicaHealthChecker(quietLogger()) - rhc.SetAlertDelay(0) + rhc.alertDelay = 0 rhc.SetAlertCallback(sink.record) rhc.Check([]*SwarmService{underReplicated("svc1", "prod_web", nil)}) @@ -72,7 +72,7 @@ func TestReplicaHealthChecker_ServiceIgnoredWhileAlertedResolves(t *testing.T) { func TestReplicaHealthChecker_EachServiceHasItsOwnAlert(t *testing.T) { sink := &alertSink{} rhc := NewReplicaHealthChecker(quietLogger()) - rhc.SetAlertDelay(0) + rhc.alertDelay = 0 rhc.SetAlertCallback(sink.record) services := []*SwarmService{underReplicated("svc1", "prod_web", nil), underReplicated("svc2", "prod_api", nil)} @@ -123,7 +123,7 @@ func serviceUpdated() runtime.RuntimeEvent { func replicaSetup(client *replicaClient, delay time.Duration) (*EventProcessor, *ServiceDiscovery, *ReplicaHealthChecker, *alertSink) { sink := &alertSink{} rhc := NewReplicaHealthChecker(quietLogger()) - rhc.SetAlertDelay(delay) + rhc.alertDelay = delay rhc.SetAlertCallback(sink.record) disc := NewServiceDiscovery(client, quietLogger()) ep := NewEventProcessor(disc, quietLogger()) diff --git a/internal/swarm/task.go b/internal/swarm/task.go deleted file mode 100644 index 53a105a7..00000000 --- a/internal/swarm/task.go +++ /dev/null @@ -1,82 +0,0 @@ -// Copyright 2026 Benjamin Touchard (kOlapsis) -// SPDX-License-Identifier: Apache-2.0 - -package swarm - -import ( - "context" - "log/slog" -) - -// TaskPlacement represents the distribution of tasks across nodes for a service. -type TaskPlacement struct { - ServiceID string `json:"service_id"` - ServiceName string `json:"service_name"` - ByNode map[string][]SwarmTask `json:"by_node"` // keyed by node ID -} - -// TaskTracker aggregates task placement information from the Docker API. -type TaskTracker struct { - client ServiceClient - logger *slog.Logger -} - -// NewTaskTracker creates a new task placement tracker. -func NewTaskTracker(client ServiceClient, logger *slog.Logger) *TaskTracker { - return &TaskTracker{ - client: client, - logger: logger, - } -} - -// GetPlacementForService returns the task distribution across nodes for a given service. -func (tt *TaskTracker) GetPlacementForService(ctx context.Context, serviceID, serviceName string) (*TaskPlacement, error) { - tasks, err := tt.client.TaskList(ctx) - if err != nil { - return nil, err - } - - nodes, err := tt.client.NodeList(ctx) - if err != nil { - tt.logger.Warn("failed to list nodes for hostname resolution", "error", err) - } - hostnames := make(map[string]string, len(nodes)) - for _, n := range nodes { - hostnames[n.ID] = n.Description.Hostname - } - - placement := &TaskPlacement{ - ServiceID: serviceID, - ServiceName: serviceName, - ByNode: make(map[string][]SwarmTask), - } - - for _, t := range tasks { - if t.ServiceID != serviceID { - continue - } - st := SwarmTask{ - TaskID: t.ID, - ServiceID: t.ServiceID, - NodeID: t.NodeID, - Slot: t.Slot, - State: string(t.Status.State), - DesiredState: string(t.DesiredState), - Timestamp: t.Status.Timestamp, - NodeHostname: hostnames[t.NodeID], - } - if t.Status.ContainerStatus != nil { - st.ContainerID = t.Status.ContainerStatus.ContainerID - if t.Status.ContainerStatus.ExitCode != 0 { - code := t.Status.ContainerStatus.ExitCode - st.ExitCode = &code - } - } - if t.Status.Err != "" { - st.Error = t.Status.Err - } - placement.ByNode[t.NodeID] = append(placement.ByNode[t.NodeID], st) - } - - return placement, nil -} diff --git a/internal/telemetry/env.go b/internal/telemetry/env.go deleted file mode 100644 index d2a709ab..00000000 --- a/internal/telemetry/env.go +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright 2026 Benjamin Touchard (Kolapsis) -// SPDX-License-Identifier: Apache-2.0 - -package telemetry - -import "strings" - -// parseDisableTelemetry returns true when the raw value of the -// MAINTENANT_DISABLE_TELEMETRY environment variable should disable telemetry. -// Truthy values: 1, t, true, y, yes, on (case-insensitive, whitespace-trimmed). -// Anything else — including 0, false, no, off, the empty string — returns -// false (telemetry stays enabled), per spec FR-005 / FR-007. -func parseDisableTelemetry(raw string) bool { - switch strings.ToLower(strings.TrimSpace(raw)) { - case "1", "t", "true", "y", "yes", "on": - return true - default: - return false - } -} diff --git a/internal/telemetry/optout_test.go b/internal/telemetry/optout_test.go index 6ad1a06e..a6d9cda3 100644 --- a/internal/telemetry/optout_test.go +++ b/internal/telemetry/optout_test.go @@ -23,7 +23,7 @@ func TestService_OptOut_NoSDKInit(t *testing.T) { beforeGoroutines := runtime.NumGoroutine() svc := New(cfg, deps, logger) - if svc.IsActive() { + if isActive(svc) { t.Fatalf("opted-out service should not be active, logs:\n%s", buf.String()) } @@ -117,7 +117,11 @@ func TestService_DefaultBoot_NotDisabled(t *testing.T) { Edition: staticEdition{value: extension.Community}, }, logger) - if !svc.IsActive() { + if !isActive(svc) { t.Fatalf("default boot should be active") } } + +func isActive(s *Service) bool { + return s != nil && s.client != nil +} diff --git a/internal/telemetry/reliability_test.go b/internal/telemetry/reliability_test.go index 60a2d57d..3a5a8ee6 100644 --- a/internal/telemetry/reliability_test.go +++ b/internal/telemetry/reliability_test.go @@ -30,7 +30,7 @@ func TestService_DatadirUnwritable(t *testing.T) { svc := New(cfg, Deps{Edition: staticEdition{value: extension.Community}}, logger) - if svc.IsActive() { + if isActive(svc) { t.Fatalf("service must be inactive when datadir is unwritable") } @@ -73,7 +73,7 @@ func TestService_ContextCancellationExits(t *testing.T) { Webhooks: fakeCounter{value: 0}, StatusComponents: fakeCounter{value: 0}, }, logger) - if !svc.IsActive() { + if !isActive(svc) { t.Fatalf("expected active service") } diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go index 5844b898..616a8756 100644 --- a/internal/telemetry/telemetry.go +++ b/internal/telemetry/telemetry.go @@ -140,12 +140,6 @@ func (s *Service) Start(ctx context.Context) { }() } -// IsActive reports whether the SDK client is initialised. Used by tests to -// assert opt-out and datadir-unwritable branches did not spawn the goroutine. -func (s *Service) IsActive() bool { - return s != nil && s.client != nil -} - func applyDefaults(cfg Config) Config { if cfg.Endpoint == "" { cfg.Endpoint = defaultEndpoint diff --git a/internal/telemetry/telemetry_integration_test.go b/internal/telemetry/telemetry_integration_test.go index ed5facce..f54e9bce 100644 --- a/internal/telemetry/telemetry_integration_test.go +++ b/internal/telemetry/telemetry_integration_test.go @@ -89,7 +89,7 @@ func TestTelemetryIntegration_DefaultBoot_SendsRecord(t *testing.T) { } svc := New(cfg, deps, logger) - if !svc.IsActive() { + if !isActive(svc) { t.Fatalf("expected active service with writable temp datadir, logs:\n%s", buf.String()) } diff --git a/internal/trust/dialinspect.go b/internal/trust/dialinspect.go index d6b952cc..9dcb3c35 100644 --- a/internal/trust/dialinspect.go +++ b/internal/trust/dialinspect.go @@ -11,12 +11,7 @@ import ( "time" ) -// DialInspect completes a TLS handshake that accepts any certificate and returns the connection state, without sending a single application byte. -func DialInspect(ctx context.Context, addr, serverName string, timeout time.Duration) (tls.ConnectionState, error) { - return DialInspectControl(ctx, addr, serverName, timeout, nil) -} - -// DialInspectControl is DialInspect with a dial-time control hook. +// DialInspectControl completes a TLS handshake that accepts any certificate and returns the connection state, without sending a single application byte; control, when set, runs at dial time. func DialInspectControl(ctx context.Context, addr, serverName string, timeout time.Duration, control func(network, address string, c syscall.RawConn) error) (tls.ConnectionState, error) { dialer := &net.Dialer{Timeout: timeout, Control: control} conn, err := dialer.DialContext(ctx, "tcp", addr) diff --git a/internal/trust/dialinspect_control_test.go b/internal/trust/dialinspect_control_test.go index 11873f96..ea4ebb86 100644 --- a/internal/trust/dialinspect_control_test.go +++ b/internal/trust/dialinspect_control_test.go @@ -66,7 +66,7 @@ func TestDialInspectControl_HookErrorAbortsBeforeConnecting(t *testing.T) { assert.Equal(t, int32(0), accepted.Load(), "no connection may be opened when the hook refuses") } -func TestDialInspectControl_NilHookBehavesLikeDialInspect(t *testing.T) { +func TestDialInspectControl_NilHookDialsNormally(t *testing.T) { srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {})) defer srv.Close() addr := strings.TrimPrefix(srv.URL, "https://") diff --git a/internal/trust/dialinspect_test.go b/internal/trust/dialinspect_test.go index 968f1d21..2b984d86 100644 --- a/internal/trust/dialinspect_test.go +++ b/internal/trust/dialinspect_test.go @@ -16,7 +16,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestDialInspect_ReturnsPeerCertificates(t *testing.T) { +func TestDialInspectControl_ReturnsPeerCertificates(t *testing.T) { srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })) @@ -24,24 +24,24 @@ func TestDialInspect_ReturnsPeerCertificates(t *testing.T) { addr := strings.TrimPrefix(srv.URL, "https://") - state, err := DialInspect(context.Background(), addr, "example.com", time.Second) + state, err := DialInspectControl(context.Background(), addr, "example.com", time.Second, nil) require.NoError(t, err) assert.NotEmpty(t, state.PeerCertificates, "the untrusted certificate must still be returned") } -func TestDialInspect_ClosedPortIsAnError(t *testing.T) { +func TestDialInspectControl_ClosedPortIsAnError(t *testing.T) { ln, err := net.Listen("tcp", "127.0.0.1:0") require.NoError(t, err) addr := ln.Addr().String() require.NoError(t, ln.Close()) // nothing listening any more - _, err = DialInspect(context.Background(), addr, "example.com", time.Second) + _, err = DialInspectControl(context.Background(), addr, "example.com", time.Second, nil) assert.Error(t, err) } -func TestDialInspect_RespectsCancelledContext(t *testing.T) { +func TestDialInspectControl_RespectsCancelledContext(t *testing.T) { srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })) @@ -52,7 +52,7 @@ func TestDialInspect_RespectsCancelledContext(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() - _, err := DialInspect(ctx, addr, "example.com", time.Second) + _, err := DialInspectControl(ctx, addr, "example.com", time.Second, nil) assert.Error(t, err) } diff --git a/internal/update/scanner_test.go b/internal/update/scanner_test.go index 03bdc497..84913610 100644 --- a/internal/update/scanner_test.go +++ b/internal/update/scanner_test.go @@ -142,7 +142,7 @@ func newTestScanner(reg registryQuerier, store UpdateStore) *Scanner { // --- T009: tag-include scanner integration test --- // TestScanner_TagInclude_FiltersTagsBeforeFindBestUpdate verifies that when -// tag-include is set, only matching tags are evaluated by FindBestUpdate. +// tag-include is set, only matching tags are evaluated by findBestUpdate. // A container on nginx:1.24 with include=^1\.25 should find 1.25.1 as an update // even though 1.26.0 is available — because 1.26.0 doesn't match the pattern. func TestScanner_TagInclude_FiltersTagsBeforeFindBestUpdate(t *testing.T) { @@ -279,7 +279,7 @@ func TestScanner_TagExclude_RemovesMatchingTags(t *testing.T) { // --- T013b: tag filter composition with track --- // TestScanner_TagFilter_CompositionWithSemver verifies that tag filters reduce the tag -// list first, then FindBestUpdate picks the best semver from the filtered set. +// list first, then findBestUpdate picks the best semver from the filtered set. // tag-include=^7\.2 with current=7.0.0 should find the highest 7.2.x tag. func TestScanner_TagFilter_CompositionWithSemver(t *testing.T) { reg := &stubRegistry{ @@ -562,14 +562,14 @@ func TestScanner_IncludeAndExclude_BothApply(t *testing.T) { require.Len(t, results, 1) // Include keeps [20.0.0, 20.1.0, 20.2.0-rc1, 20.3.0] // Exclude removes [20.2.0-rc1] - // FindBestUpdate picks 20.3.0 (semver skips prereleases anyway, but exclude removes rc first) + // findBestUpdate picks 20.3.0 (semver skips prereleases anyway, but exclude removes rc first) assert.Equal(t, "20.3.0", results[0].LatestTag) } // --- No labels — default behavior preserved --- // TestScanner_NoLabels_DefaultBehavior verifies that without tag filter labels, -// the scanner uses the existing default behavior (variant filter via FindBestUpdate). +// the scanner uses the existing default behavior (variant filter via findBestUpdate). func TestScanner_NoLabels_DefaultBehavior(t *testing.T) { reg := &stubRegistry{ tags: map[string][]string{ diff --git a/internal/update/semver.go b/internal/update/semver.go index 0563c013..b82507cf 100644 --- a/internal/update/semver.go +++ b/internal/update/semver.go @@ -137,24 +137,6 @@ func sortTagVersions(tags []string, variant string, requireDots bool, precision return result } -// SortTags filters non-semver tags and returns sorted semver versions (ascending). -func SortTags(tags []string) []*semver.Version { - var versions []*semver.Version - for _, tag := range tags { - v, err := semver.NewVersion(tag) - if err != nil { - continue - } - // Skip pre-release versions - if v.Prerelease() != "" { - continue - } - versions = append(versions, v) - } - sort.Sort(semver.Collection(versions)) - return versions -} - // isFixedVersionTag reports whether a tag names a single release (major.minor.patch) rather than a line the registry moves. func isFixedVersionTag(tag string) bool { versionPart, _ := splitVariant(tag) @@ -209,7 +191,8 @@ func withinTrack(current, candidate *semver.Version, level string) bool { } } -// FindBestUpdate finds the best available update for the given current tag among all tags. +// findBestUpdate finds the best available update for the given current tag among all tags, +// restricted to candidates within the tracked level (TrackMajor, TrackMinor or TrackPatch). // For pinned semver tags (major.minor.patch): finds the highest version with the same // variant suffix (e.g. -alpine). // For floating tags (non-semver channels like "latest" or "lts", and partial versions @@ -217,12 +200,6 @@ func withinTrack(current, candidate *semver.Version, level string) bool { // line, so a more precise tag underneath it is what the container runs, not an update. // Only a higher tag of the same shape counts; otherwise the same tag is returned so the // scanner compares digests. -func FindBestUpdate(currentTag string, allTags []string) (bestTag string, updateType UpdateType) { - return findBestUpdate(currentTag, allTags, TrackMajor) -} - -// findBestUpdate is FindBestUpdate restricted to candidates within the tracked level -// (TrackMajor, TrackMinor or TrackPatch). func findBestUpdate(currentTag string, allTags []string, level string) (bestTag string, updateType UpdateType) { versionPart, variant := splitVariant(currentTag) diff --git a/internal/update/semver_test.go b/internal/update/semver_test.go index a2f8e37f..4fac25fe 100644 --- a/internal/update/semver_test.go +++ b/internal/update/semver_test.go @@ -193,7 +193,7 @@ func TestFindBestUpdate_VariantSuffix(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - gotTag, gotType := FindBestUpdate(tt.currentTag, tt.allTags) + gotTag, gotType := findBestUpdate(tt.currentTag, tt.allTags, TrackMajor) assert.Equal(t, tt.wantTag, gotTag) assert.Equal(t, tt.wantType, gotType) }) diff --git a/internal/update/service.go b/internal/update/service.go index 7af1e7fa..d75dda91 100644 --- a/internal/update/service.go +++ b/internal/update/service.go @@ -104,16 +104,6 @@ func NewService(d Deps) *Service { } } -// SetEnricher sets the update enricher. -func (s *Service) SetEnricher(e Enricher) { - s.enricher = e -} - -// SetAlertChannel sets the alert engine's event channel for critical notifications. -func (s *Service) SetAlertChannel(ch chan<- interface{}) { - s.alertChan = ch -} - // SetEventCallback sets the SSE broadcasting callback. func (s *Service) SetEventCallback(fn EventCallback) { s.eventCallback = fn