command-lifecycle v2: add received state, disk-persisted dedup, authenticated download, timeout reconciler
Migration 033 adds the 'received' status to agent_commands so the server can
distinguish "agent confirmed receipt" from "sent but may be lost in flight."
Stuck-command re-issuance now excludes received commands — the TimeoutService
handles the longer timeout for those (default 30m) vs the per-poll re-issuer
(sent/pending at 5m).
The agent side: disk-persists executed command IDs to survive restart (closes
the in-memory-only dedup gap), reports received_command_ids on each check-in so
the server transitions sent→received before issuing new work, and authenticates
binary downloads with JWT+X-Machine-ID (was unauthenticated http.Get — would
401 in production).
TimeoutService extended with reconcileAgentUpdates: clears is_updating when
current_version matches updating_to_version (success), or after a 15m threshold
(timeout, with system_event) so the dashboard never shows "updating" forever.
isVersionUpgrade replaced with utils.IsNewerVersion (no panic on 2-part
versions, no false-reject on 4-part).
MarkCommand* failures elevated from [WARNING] to [ERROR] + should_retry
response hint so agents know to re-deliver results (silent drops were ETHOS #1
violations).
Fixes: build broken on public since eac8a012 (command.go accidentally emptied).
This commit is contained in:
parent
eac8a012bf
commit
7abe331bf8
19 changed files with 920 additions and 122 deletions
|
|
@ -455,6 +455,11 @@ If you're looking for an enterprise-grade solution with SLAs and support contrac
|
|||
- ✅ Test suite: 170+ tests across 18 packages
|
||||
- ✅ Install URL auto-detection: backend uses REDFLAG_PUBLIC_URL, frontend :8080 → :31337
|
||||
- ✅ Setup wizard includes agent-facing URL field with auto-detect
|
||||
- ✅ Command lifecycle v2 (Migration 033): `received` state distinguishes "agent has it" from "lost in flight"; stuck-command re-issuance no longer fires blindly
|
||||
- ✅ Disk-persisted command deduplication: a restarted agent cannot re-execute a command issued within the 4h max-age window
|
||||
- ✅ Agent self-upgrade completion loop: TimeoutService reconciles `is_updating` on version attestation (success) or after threshold (timeout, with system_event)
|
||||
- ✅ Authenticated binary download: agent self-upgrade now reuses JWT + X-Machine-ID for the download endpoint (was unauthenticated, would 401 in production)
|
||||
- ✅ Surfaced MarkCommand* failures: silent warnings → ERROR logs + `should_retry` hint on the response (ETHOS #1)
|
||||
|
||||
**v0.1.27 (Dec 2025, Christmas Release) 🎄**:
|
||||
- ✅ Hardware binding with machine fingerprinting (security differentiator)
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import (
|
|||
"github.com/Fimeg/RedFlag/agent/internal/logging"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/models"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/orchestrator"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/receipt"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/recovery"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/scanner"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/startup"
|
||||
|
|
@ -86,15 +87,22 @@ func RunAgentLoop(cfg *config.Config) error {
|
|||
scanOrchestrator.RegisterScanner("system", orchestrator.NewSystemScannerWrapper(systemScanner), systemCB, cfg.Subsystems.System.Timeout, cfg.Subsystems.System.Enabled)
|
||||
scanOrchestrator.RegisterScanner("docker", orchestrator.NewDockerScannerWrapper(dockerScanner), dockerCB, cfg.Subsystems.Docker.Timeout, cfg.Subsystems.Docker.Enabled)
|
||||
|
||||
// Initialize acknowledgment tracker
|
||||
// Initialize acknowledgment tracker (result acks — pending_acks.json)
|
||||
ackTracker := acknowledgment.NewTracker(constants.GetAgentStateDir())
|
||||
if err := ackTracker.Load(); err != nil {
|
||||
log.Printf("Warning: Failed to load pending acknowledgments: %v", err)
|
||||
log.Printf("[WARNING] [agent] [acknowledgment] load_pending_acks_failed error=%v", err)
|
||||
}
|
||||
|
||||
// Initialize receipt tracker (command-receipt confirmation — pending_receipts.json)
|
||||
// Migration 033 §2: doctrine TODO-full-command-lifecycle.md
|
||||
receiptTracker := receipt.NewTracker(constants.GetAgentStateDir())
|
||||
if err := receiptTracker.Load(); err != nil {
|
||||
log.Printf("[WARNING] [agent] [receipt] load_pending_receipts_failed error=%v", err)
|
||||
}
|
||||
|
||||
// Initialize command handler
|
||||
securityLogger, _ := logging.NewSecurityLogger(cfg, constants.GetAgentStateDir())
|
||||
commandHandler, err := orchestrator.NewCommandHandler(cfg, securityLogger, log.New(os.Stdout, "", log.LstdFlags))
|
||||
commandHandler, err := orchestrator.NewCommandHandler(cfg, constants.GetAgentStateDir(), securityLogger, log.New(os.Stdout, "", log.LstdFlags))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize command handler: %w", err)
|
||||
}
|
||||
|
|
@ -104,6 +112,7 @@ func RunAgentLoop(cfg *config.Config) error {
|
|||
cfg: cfg,
|
||||
apiClient: apiClient,
|
||||
ackTracker: ackTracker,
|
||||
receiptTracker: receiptTracker,
|
||||
commandHandler: commandHandler,
|
||||
scanOrchestrator: scanOrchestrator,
|
||||
circuitBreakers: map[string]*circuitbreaker.CircuitBreaker{
|
||||
|
|
@ -123,6 +132,7 @@ type loopContext struct {
|
|||
cfg *config.Config
|
||||
apiClient *client.Client
|
||||
ackTracker *acknowledgment.Tracker
|
||||
receiptTracker *receipt.Tracker
|
||||
commandHandler *orchestrator.CommandHandler
|
||||
scanOrchestrator *orchestrator.Orchestrator
|
||||
circuitBreakers map[string]*circuitbreaker.CircuitBreaker
|
||||
|
|
@ -170,8 +180,15 @@ func runPollingLoop(ctx *loopContext) error {
|
|||
|
||||
log.Printf("Checking in with server... (Agent v%s)", version.Version)
|
||||
|
||||
// Collect system metrics
|
||||
// Collect system metrics, then bolt on the tracked IDs so the server can both
|
||||
// (a) acknowledge command results we're still buffering (pending_acks.json) and
|
||||
// (b) confirm receipt of commands we received last round but haven't completed
|
||||
// (pending_receipts.json, Migration 033 §2).
|
||||
metrics := collectMetrics(ctx.cfg)
|
||||
if metrics != nil {
|
||||
metrics.PendingAcknowledgments = ctx.ackTracker.GetPending()
|
||||
metrics.ReceivedCommandIDs = ctx.receiptTracker.GetPending()
|
||||
}
|
||||
|
||||
// Get commands from server
|
||||
response, err := ctx.apiClient.GetCommands(ctx.cfg.AgentID, metrics)
|
||||
|
|
@ -192,11 +209,22 @@ func runPollingLoop(ctx *loopContext) error {
|
|||
"rapid_polling": ctx.cfg.RapidPollingEnabled && time.Now().Before(ctx.cfg.RapidPollingUntil),
|
||||
})
|
||||
|
||||
// Process acknowledgments
|
||||
// Drop result-acks the server confirmed.
|
||||
if response != nil && len(response.AcknowledgedIDs) > 0 {
|
||||
ctx.ackTracker.Acknowledge(response.AcknowledgedIDs)
|
||||
log.Printf("Server acknowledged %d command result(s)", len(response.AcknowledgedIDs))
|
||||
ctx.ackTracker.Save()
|
||||
log.Printf("[INFO] [agent] [acknowledgment] results_acknowledged count=%d", len(response.AcknowledgedIDs))
|
||||
if err := ctx.ackTracker.Save(); err != nil {
|
||||
log.Printf("[ERROR] [agent] [acknowledgment] save_failed error=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Drop receipts the server confirmed (sent→received transition successful).
|
||||
if response != nil && len(response.ReceiptConfirmedIDs) > 0 {
|
||||
ctx.receiptTracker.Confirm(response.ReceiptConfirmedIDs)
|
||||
log.Printf("[INFO] [agent] [receipt] receipts_confirmed count=%d", len(response.ReceiptConfirmedIDs))
|
||||
if err := ctx.receiptTracker.Save(); err != nil {
|
||||
log.Printf("[ERROR] [agent] [receipt] save_failed error=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Report circuit breaker health
|
||||
|
|
@ -205,9 +233,16 @@ func runPollingLoop(ctx *loopContext) error {
|
|||
// Report buffered events [TD-003]
|
||||
go reportBufferedEvents(ctx)
|
||||
|
||||
// Process commands
|
||||
// Process commands — record receipt BEFORE verification/dispatch so the server
|
||||
// stops re-issuing immediately, even for commands that turn out malformed.
|
||||
if len(response.Commands) > 0 {
|
||||
log.Printf("Received %d command(s)", len(response.Commands))
|
||||
for _, cmd := range response.Commands {
|
||||
ctx.receiptTracker.Add(cmd.ID)
|
||||
}
|
||||
if err := ctx.receiptTracker.Save(); err != nil {
|
||||
log.Printf("[ERROR] [agent] [receipt] save_after_receive_failed error=%v", err)
|
||||
}
|
||||
processCommands(ctx, response.Commands)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
|
|
@ -173,6 +174,68 @@ func (c *Client) SetToken(token string) {
|
|||
c.token = token
|
||||
}
|
||||
|
||||
// DownloadAuthenticatedToFile fetches a relative or absolute URL using the agent's
|
||||
// JWT + machine binding and streams the body into dstPath. maxBytes caps the size to
|
||||
// guard against a misbehaving server filling the disk. Returns the number of bytes
|
||||
// written, or an error.
|
||||
//
|
||||
// Relative URLs (those beginning with "/") are resolved against the configured server.
|
||||
// Used by the agent self-update handler — the /api/v1/downloads/updates/:package_id
|
||||
// route is auth-protected, so an unauthenticated http.Get would 401.
|
||||
func (c *Client) DownloadAuthenticatedToFile(rawURL, dstPath string, maxBytes int64) (int64, error) {
|
||||
target := rawURL
|
||||
if strings.HasPrefix(rawURL, "/") {
|
||||
base, err := url.Parse(c.baseURL)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("parse base url: %w", err)
|
||||
}
|
||||
rel, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("parse download url: %w", err)
|
||||
}
|
||||
target = base.ResolveReference(rel).String()
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", target, nil)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("build download request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
c.addMachineIDHeader(req)
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("download request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return 0, fmt.Errorf("download failed: status=%d body=%q", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
dst, err := os.Create(dstPath)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("create destination: %w", err)
|
||||
}
|
||||
defer dst.Close()
|
||||
|
||||
limit := io.LimitReader(resp.Body, maxBytes)
|
||||
written, err := io.Copy(dst, limit)
|
||||
if err != nil {
|
||||
return written, fmt.Errorf("write download: %w", err)
|
||||
}
|
||||
|
||||
// Detect truncation: if we hit exactly maxBytes, the source may have been longer.
|
||||
// Probe by reading one more byte.
|
||||
probe := make([]byte, 1)
|
||||
if n, _ := resp.Body.Read(probe); n > 0 {
|
||||
return written, fmt.Errorf("download exceeded max bytes %d", maxBytes)
|
||||
}
|
||||
|
||||
return written, nil
|
||||
}
|
||||
|
||||
// RegisterRequest is the payload for agent registration
|
||||
type RegisterRequest struct {
|
||||
Hostname string `json:"hostname"`
|
||||
|
|
@ -397,9 +460,10 @@ type CommandItem = Command
|
|||
|
||||
// CommandsResponse contains pending commands
|
||||
type CommandsResponse struct {
|
||||
Commands []Command `json:"commands"`
|
||||
RapidPolling *RapidPollingConfig `json:"rapid_polling,omitempty"`
|
||||
AcknowledgedIDs []string `json:"acknowledged_ids,omitempty"` // IDs server has received
|
||||
Commands []Command `json:"commands"`
|
||||
RapidPolling *RapidPollingConfig `json:"rapid_polling,omitempty"`
|
||||
AcknowledgedIDs []string `json:"acknowledged_ids,omitempty"` // Result IDs server has recorded (drop from pending_acks)
|
||||
ReceiptConfirmedIDs []string `json:"receipt_confirmed_ids,omitempty"` // Command IDs server flipped sent→received (drop from outbound receipts)
|
||||
}
|
||||
|
||||
// RapidPollingConfig contains rapid polling configuration from server
|
||||
|
|
@ -422,7 +486,12 @@ type SystemMetrics struct {
|
|||
Metadata map[string]interface{} `json:"metadata,omitempty"` // Additional metadata
|
||||
|
||||
// Command acknowledgment tracking
|
||||
PendingAcknowledgments []string `json:"pending_acknowledgments,omitempty"` // Command IDs awaiting ACK
|
||||
PendingAcknowledgments []string `json:"pending_acknowledgments,omitempty"` // Command result IDs awaiting server ACK
|
||||
|
||||
// Receipt confirmation (Migration 033 §2): command IDs the agent has received but
|
||||
// not yet completed. Server flips these sent→received and returns them in
|
||||
// ReceiptConfirmedIDs, at which point the agent drops them from its outbound buffer.
|
||||
ReceivedCommandIDs []string `json:"received_command_ids,omitempty"`
|
||||
|
||||
// Capability advertisement (ARC-001): scanners present on this host right now.
|
||||
// Server diffs this against agent_subsystems each poll so newly-installed
|
||||
|
|
|
|||
|
|
@ -9,12 +9,9 @@ import (
|
|||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/acknowledgment"
|
||||
|
|
@ -97,21 +94,11 @@ func HandleUpdateAgent(apiClient *client.Client, cfg *config.Config, ackTracker
|
|||
|
||||
log.Printf("[INFO] [agent] [update] download_start url=%s", downloadURL)
|
||||
|
||||
// Resolve relative download URLs against the server address
|
||||
if strings.HasPrefix(downloadURL, "/") {
|
||||
base, err := url.Parse(cfg.ServerURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse server URL: %w", err)
|
||||
}
|
||||
rel, err := url.Parse(downloadURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse download URL: %w", err)
|
||||
}
|
||||
downloadURL = base.ResolveReference(rel).String()
|
||||
log.Printf("[INFO] [agent] [update] resolved_url=%s", downloadURL)
|
||||
}
|
||||
|
||||
tempBinaryPath, err := downloadUpdatePackage(downloadURL)
|
||||
// The /api/v1/downloads/updates/:package_id route is protected by AuthMiddleware
|
||||
// — the prior implementation used a raw http.Client.Get and would 401 in
|
||||
// production. Use the authenticated client so JWT + X-Machine-ID accompany the
|
||||
// request. Relative-URL resolution happens inside DownloadAuthenticatedToFile.
|
||||
tempBinaryPath, err := downloadUpdatePackage(apiClient, downloadURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download update package: %w", err)
|
||||
}
|
||||
|
|
@ -205,31 +192,24 @@ func HandleUpdateAgent(apiClient *client.Client, cfg *config.Config, ackTracker
|
|||
|
||||
// --- Helper functions ---
|
||||
|
||||
func downloadUpdatePackage(downloadURL string) (string, error) {
|
||||
// downloadUpdatePackage streams the new agent binary into a temp file using the
|
||||
// agent's authenticated client. Caller owns the returned path (use defer os.Remove).
|
||||
// The 500MB cap mirrors the prior implementation; an oversize response returns an
|
||||
// error rather than a silently-truncated binary that would fail signature verify.
|
||||
func downloadUpdatePackage(apiClient *client.Client, downloadURL string) (string, error) {
|
||||
tempFile, err := os.CreateTemp("", "redflag-update-*.bin")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create temp file: %w", err)
|
||||
}
|
||||
defer tempFile.Close()
|
||||
|
||||
client := &http.Client{Timeout: 5 * time.Minute}
|
||||
resp, err := client.Get(downloadURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to download: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("download failed with status: %d", resp.StatusCode)
|
||||
}
|
||||
tempPath := tempFile.Name()
|
||||
tempFile.Close() // DownloadAuthenticatedToFile re-opens via os.Create
|
||||
|
||||
const maxBinarySize = 500 * 1024 * 1024
|
||||
limitedReader := io.LimitReader(resp.Body, maxBinarySize)
|
||||
if _, err := io.Copy(tempFile, limitedReader); err != nil {
|
||||
return "", fmt.Errorf("failed to write download: %w", err)
|
||||
if _, err := apiClient.DownloadAuthenticatedToFile(downloadURL, tempPath, maxBinarySize); err != nil {
|
||||
os.Remove(tempPath)
|
||||
return "", fmt.Errorf("failed to download: %w", err)
|
||||
}
|
||||
|
||||
return tempFile.Name(), nil
|
||||
return tempPath, nil
|
||||
}
|
||||
|
||||
func computeSHA256(filePath string) (string, error) {
|
||||
|
|
|
|||
|
|
@ -2,8 +2,11 @@ package orchestrator
|
|||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
|
|
@ -31,6 +34,7 @@ type CommandHandler struct {
|
|||
keyCacheMu sync.RWMutex
|
||||
executedIDs map[string]time.Time // cmd UUID -> execution time (F-2 fix: dedup)
|
||||
executedIDsMu sync.Mutex
|
||||
executedIDsPath string // Migration 033 §5: disk-persisted dedup
|
||||
lastKeyRefresh time.Time
|
||||
logger *log.Logger
|
||||
}
|
||||
|
|
@ -41,8 +45,9 @@ type CommandSigningConfig struct {
|
|||
EnforcementMode string `json:"enforcement_mode" env:"REDFLAG_AGENT_COMMAND_ENFORCEMENT_MODE" default:"strict"`
|
||||
}
|
||||
|
||||
// NewCommandHandler creates a new command handler
|
||||
func NewCommandHandler(cfg *config.Config, securityLogger *logging.SecurityLogger, logger *log.Logger) (*CommandHandler, error) {
|
||||
// NewCommandHandler creates a new command handler. stateDir is the agent's persistent
|
||||
// state directory; passing an empty string disables disk-persisted dedup (test path).
|
||||
func NewCommandHandler(cfg *config.Config, stateDir string, securityLogger *logging.SecurityLogger, logger *log.Logger) (*CommandHandler, error) {
|
||||
handler := &CommandHandler{
|
||||
securityLogger: securityLogger,
|
||||
logger: logger,
|
||||
|
|
@ -50,6 +55,21 @@ func NewCommandHandler(cfg *config.Config, securityLogger *logging.SecurityLogge
|
|||
keyCache: make(map[string]ed25519.PublicKey),
|
||||
executedIDs: make(map[string]time.Time),
|
||||
}
|
||||
if stateDir != "" {
|
||||
handler.executedIDsPath = filepath.Join(stateDir, "executed_commands.json")
|
||||
}
|
||||
|
||||
// Migration 033 §5: rebuild dedup set from disk so a restart can't re-execute a
|
||||
// command issued within commandMaxAge. Missing file is a fresh start, not an error.
|
||||
if handler.executedIDsPath != "" {
|
||||
if err := handler.loadExecutedIDs(); err != nil {
|
||||
logger.Printf("[WARNING] [agent] [cmd_handler] load_executed_ids_failed path=%q error=%v", handler.executedIDsPath, err)
|
||||
} else {
|
||||
logger.Printf("[INFO] [agent] [cmd_handler] executed_ids_loaded count=%d path=%q", len(handler.executedIDs), handler.executedIDsPath)
|
||||
// Trim anything that's already aged out — keeps the file from growing forever.
|
||||
handler.CleanupExecutedIDs()
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-load cached public key if command signing is enabled
|
||||
if cfg.CommandSigning.Enabled {
|
||||
|
|
@ -67,6 +87,53 @@ func NewCommandHandler(cfg *config.Config, securityLogger *logging.SecurityLogge
|
|||
return handler, nil
|
||||
}
|
||||
|
||||
// loadExecutedIDs restores the dedup set from disk. Caller holds no lock.
|
||||
func (h *CommandHandler) loadExecutedIDs() error {
|
||||
if _, err := os.Stat(h.executedIDsPath); os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
data, err := os.ReadFile(h.executedIDsPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read: %w", err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
var loaded map[string]time.Time
|
||||
if err := json.Unmarshal(data, &loaded); err != nil {
|
||||
return fmt.Errorf("parse: %w", err)
|
||||
}
|
||||
h.executedIDsMu.Lock()
|
||||
h.executedIDs = loaded
|
||||
h.executedIDsMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// saveExecutedIDs persists the current dedup set. Caller MUST hold executedIDsMu.
|
||||
// Errors are logged by the caller — best-effort persistence; the in-memory set is
|
||||
// the authoritative source within a single process lifetime.
|
||||
func (h *CommandHandler) saveExecutedIDsLocked() error {
|
||||
if h.executedIDsPath == "" {
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(h.executedIDsPath), 0o755); err != nil {
|
||||
return fmt.Errorf("mkdir: %w", err)
|
||||
}
|
||||
data, err := json.MarshalIndent(h.executedIDs, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal: %w", err)
|
||||
}
|
||||
tmp := h.executedIDsPath + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o600); err != nil {
|
||||
return fmt.Errorf("write: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmp, h.executedIDsPath); err != nil {
|
||||
os.Remove(tmp)
|
||||
return fmt.Errorf("rename: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// getKeyForCommand returns the appropriate public key for verifying a command.
|
||||
// Uses key_id-aware lookup with lazy fetch for unknown keys.
|
||||
func (h *CommandHandler) getKeyForCommand(cmd client.Command, serverURL string) (ed25519.PublicKey, error) {
|
||||
|
|
@ -196,15 +263,21 @@ func (h *CommandHandler) ProcessCommand(cmd client.Command, cfg *config.Config,
|
|||
return nil
|
||||
}
|
||||
|
||||
// markExecuted records a command ID in the deduplication set (F-2 fix)
|
||||
// markExecuted records a command ID in the deduplication set (F-2 fix).
|
||||
// Persists the updated set to disk so a restart-within-commandMaxAge cannot re-execute
|
||||
// the same command (Migration 033 §5).
|
||||
func (h *CommandHandler) markExecuted(cmdID string) {
|
||||
h.executedIDsMu.Lock()
|
||||
h.executedIDs[cmdID] = time.Now()
|
||||
if err := h.saveExecutedIDsLocked(); err != nil {
|
||||
h.logger.Printf("[ERROR] [agent] [cmd_handler] persist_executed_ids_failed cmd=%s error=%v", cmdID, err)
|
||||
}
|
||||
h.executedIDsMu.Unlock()
|
||||
}
|
||||
|
||||
// CleanupExecutedIDs evicts entries older than commandMaxAge from the dedup set.
|
||||
// Should be called when ShouldRefreshKey() fires (every 6h).
|
||||
// Should be called when ShouldRefreshKey() fires (every 6h). Persists the trimmed
|
||||
// set if anything was evicted.
|
||||
func (h *CommandHandler) CleanupExecutedIDs() {
|
||||
h.executedIDsMu.Lock()
|
||||
defer h.executedIDsMu.Unlock()
|
||||
|
|
@ -219,6 +292,9 @@ func (h *CommandHandler) CleanupExecutedIDs() {
|
|||
}
|
||||
if evicted > 0 {
|
||||
h.logger.Printf("[INFO] [agent] [cmd_handler] cleanup_executed_ids evicted=%d remaining=%d", evicted, len(h.executedIDs))
|
||||
if err := h.saveExecutedIDsLocked(); err != nil {
|
||||
h.logger.Printf("[ERROR] [agent] [cmd_handler] persist_after_cleanup_failed error=%v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
152
agent/internal/receipt/tracker.go
Normal file
152
agent/internal/receipt/tracker.go
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
// Package receipt persists the set of command IDs the agent has received from the
|
||||
// server but not yet seen the server confirm as transitioned to status='received'.
|
||||
//
|
||||
// Doctrine: TODO-full-command-lifecycle.md §2. The agent reports these in
|
||||
// SystemMetrics.ReceivedCommandIDs each check-in. The server flips matching rows from
|
||||
// 'sent' to 'received' and echoes the confirmed subset back in
|
||||
// CommandsResponse.ReceiptConfirmedIDs; the agent then drops only the confirmed IDs.
|
||||
// The unsent ones stay buffered so a dropped check-in doesn't lose receipt confirmation.
|
||||
//
|
||||
// This is distinct from pending_acks.json, which tracks command RESULTS awaiting
|
||||
// server confirmation. Receipt happens BEFORE dispatch; result happens AFTER.
|
||||
package receipt
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// pendingReceipt captures when the agent first received a command — used to age out
|
||||
// entries the server has somehow forgotten so they don't accumulate forever.
|
||||
type pendingReceipt struct {
|
||||
CommandID string `json:"command_id"`
|
||||
ReceivedAt time.Time `json:"received_at"`
|
||||
}
|
||||
|
||||
// Tracker is a disk-persisted set of command IDs awaiting server-side receipt confirmation.
|
||||
type Tracker struct {
|
||||
pending map[string]*pendingReceipt
|
||||
mu sync.RWMutex
|
||||
filePath string
|
||||
maxAge time.Duration // discard buffered receipts older than this so a permanent server-side loss doesn't leak forever
|
||||
}
|
||||
|
||||
// NewTracker creates a tracker that persists state under statePath/pending_receipts.json.
|
||||
func NewTracker(statePath string) *Tracker {
|
||||
return &Tracker{
|
||||
pending: make(map[string]*pendingReceipt),
|
||||
filePath: filepath.Join(statePath, "pending_receipts.json"),
|
||||
maxAge: 24 * time.Hour,
|
||||
}
|
||||
}
|
||||
|
||||
// Load restores pending receipts from disk. Missing file is not an error (fresh start).
|
||||
func (t *Tracker) Load() error {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
if _, err := os.Stat(t.filePath); os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(t.filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read pending_receipts: %w", err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var pending map[string]*pendingReceipt
|
||||
if err := json.Unmarshal(data, &pending); err != nil {
|
||||
return fmt.Errorf("parse pending_receipts: %w", err)
|
||||
}
|
||||
t.pending = pending
|
||||
return nil
|
||||
}
|
||||
|
||||
// Save persists the pending set to disk.
|
||||
func (t *Tracker) Save() error {
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(t.filePath), 0o755); err != nil {
|
||||
return fmt.Errorf("create receipt dir: %w", err)
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(t.pending, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal pending_receipts: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(t.filePath, data, 0o600); err != nil {
|
||||
return fmt.Errorf("write pending_receipts: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Add records that the agent has received a command from the server. Safe to call
|
||||
// multiple times with the same ID — duplicates are a no-op (idempotent, ETHOS #4).
|
||||
func (t *Tracker) Add(commandID string) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if _, exists := t.pending[commandID]; exists {
|
||||
return
|
||||
}
|
||||
t.pending[commandID] = &pendingReceipt{
|
||||
CommandID: commandID,
|
||||
ReceivedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// GetPending returns the current pending set as a slice (snapshot — safe to mutate).
|
||||
// Order is not stable.
|
||||
func (t *Tracker) GetPending() []string {
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
ids := make([]string, 0, len(t.pending))
|
||||
for id := range t.pending {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// Confirm removes the IDs the server confirmed receipt of. IDs not in the pending
|
||||
// set are silently ignored — server might confirm an ID we already dropped after
|
||||
// a successful prior round-trip, or an old buffer survived a crash.
|
||||
func (t *Tracker) Confirm(commandIDs []string) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
for _, id := range commandIDs {
|
||||
delete(t.pending, id)
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup discards receipts older than maxAge. Returns the count removed. Should be
|
||||
// run periodically to bound disk and memory growth in the pathological case where
|
||||
// the server forgets a command id permanently.
|
||||
func (t *Tracker) Cleanup() int {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
removed := 0
|
||||
for id, p := range t.pending {
|
||||
if now.Sub(p.ReceivedAt) > t.maxAge {
|
||||
delete(t.pending, id)
|
||||
removed++
|
||||
}
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
// Len returns the current size of the pending set.
|
||||
func (t *Tracker) Len() int {
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
return len(t.pending)
|
||||
}
|
||||
|
|
@ -124,7 +124,7 @@ func (s *redflagService) initialize() error {
|
|||
|
||||
// CRITICAL: Initialize command handler with signature verification
|
||||
// If this fails, we MUST NOT allow the service to run without verification (ETHOS #2)
|
||||
commandHandler, err := orchestrator.NewCommandHandler(s.agent, securityLogger, log.New(os.Stdout, "", log.LstdFlags))
|
||||
commandHandler, err := orchestrator.NewCommandHandler(s.agent, constants.GetAgentStateDir(), securityLogger, log.New(os.Stdout, "", log.LstdFlags))
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] [agent] [cmd_handler] init_failed error=\"%v\"", err)
|
||||
elog.Error(1, fmt.Sprintf("Command handler init failed: %v", err))
|
||||
|
|
|
|||
|
|
@ -291,12 +291,14 @@ func main() {
|
|||
tokenCleanupInterval := time.Duration(getOperationalSetting(securitySettingsService, "token_cleanup_interval_hours", 24)) * time.Hour
|
||||
sentTimeout := time.Duration(getOperationalSetting(securitySettingsService, "sent_command_timeout_hours", 2)) * time.Hour
|
||||
pendingTimeout := time.Duration(getOperationalSetting(securitySettingsService, "pending_command_timeout_minutes", 30)) * time.Minute
|
||||
receivedTimeout := time.Duration(getOperationalSetting(securitySettingsService, "received_command_timeout_minutes", 30)) * time.Minute
|
||||
updateTimeout := time.Duration(getOperationalSetting(securitySettingsService, "agent_update_timeout_minutes", 15)) * time.Minute
|
||||
checkInterval := time.Duration(getOperationalSetting(securitySettingsService, "timeout_check_interval_minutes", 5)) * time.Minute
|
||||
|
||||
log.Printf("[INFO] [server] [config] operational_timeouts_loaded offline_check=%s offline_threshold=%s token_cleanup=%s sent_cmd_timeout=%s pending_cmd_timeout=%s timeout_check=%s",
|
||||
offlineCheckInterval, offlineThreshold, tokenCleanupInterval, sentTimeout, pendingTimeout, checkInterval)
|
||||
log.Printf("[INFO] [server] [config] operational_timeouts_loaded offline_check=%s offline_threshold=%s token_cleanup=%s sent_cmd_timeout=%s pending_cmd_timeout=%s received_cmd_timeout=%s update_timeout=%s timeout_check=%s",
|
||||
offlineCheckInterval, offlineThreshold, tokenCleanupInterval, sentTimeout, pendingTimeout, receivedTimeout, updateTimeout, checkInterval)
|
||||
|
||||
timeoutService := services.NewTimeoutService(commandQueries, updateQueries, sentTimeout, pendingTimeout, checkInterval)
|
||||
timeoutService := services.NewTimeoutService(commandQueries, updateQueries, agentQueries, sentTimeout, pendingTimeout, receivedTimeout, updateTimeout, checkInterval)
|
||||
|
||||
// Check if setup is complete
|
||||
if !isSetupComplete(cfg, signingService, db) {
|
||||
|
|
|
|||
|
|
@ -377,10 +377,11 @@ func (h *AgentHandler) GetCommands(c *gin.Context) {
|
|||
DiskTotalGB float64 `json:"disk_total_gb,omitempty"`
|
||||
DiskPercent float64 `json:"disk_percent,omitempty"`
|
||||
Uptime string `json:"uptime,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
PendingAcknowledgments []string `json:"pending_acknowledgments,omitempty"`
|
||||
AvailableScanners []string `json:"available_scanners,omitempty"` // ARC-001: per-poll capability advertisement
|
||||
ReceivedCommandIDs []string `json:"received_command_ids,omitempty"` // Migration 033: command IDs the agent has received but not yet completed
|
||||
Version string `json:"version,omitempty"` // Agent's currently-running version; drives post-update IsUpdating clearance via TimeoutService
|
||||
AvailableScanners []string `json:"available_scanners,omitempty"` // ARC-001: per-poll capability advertisement
|
||||
}
|
||||
|
||||
// Parse metrics if provided (optional, won't fail if empty)
|
||||
|
|
@ -606,6 +607,35 @@ func (h *AgentHandler) GetCommands(c *gin.Context) {
|
|||
}
|
||||
defer cmdTx.Rollback()
|
||||
|
||||
// Post-update version attestation: if the agent reports a version, record it. The
|
||||
// IsUpdating flag clearance is handled by TimeoutService once it observes
|
||||
// current_version == updating_to_version (avoids per-request side effects).
|
||||
if metrics.Version != "" {
|
||||
if err := h.agentQueries.UpdateAgentVersion(agentID, metrics.Version); err != nil {
|
||||
log.Printf("[ERROR] [server] [command] update_version_failed agent_id=%s version=%s error=%v",
|
||||
agentID, metrics.Version, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Migration 033 §2: transition agent-reported sent→received BEFORE looking for stuck
|
||||
// commands. Excludes those IDs from the GetStuckCommandsTx re-issuance candidate set,
|
||||
// which now only sees 'pending' and 'sent'. Receipt confirmation goes back in the
|
||||
// response so the agent can drop them from its outbox.
|
||||
var receiptConfirmedIDs []string
|
||||
if len(metrics.ReceivedCommandIDs) > 0 {
|
||||
confirmed, markErr := h.commandQueries.MarkCommandsReceivedTx(cmdTx, agentID, metrics.ReceivedCommandIDs)
|
||||
if markErr != nil {
|
||||
log.Printf("[ERROR] [server] [command] mark_received_failed agent_id=%s reported=%d error=%v",
|
||||
agentID, len(metrics.ReceivedCommandIDs), markErr)
|
||||
} else {
|
||||
receiptConfirmedIDs = confirmed
|
||||
if len(confirmed) > 0 {
|
||||
log.Printf("[INFO] [server] [command] commands_received agent_id=%s confirmed=%d reported=%d",
|
||||
agentID, len(confirmed), len(metrics.ReceivedCommandIDs))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get pending commands with row-level lock
|
||||
pendingCommands, err := h.commandQueries.GetPendingCommandsTx(cmdTx, agentID)
|
||||
if err != nil {
|
||||
|
|
@ -614,7 +644,7 @@ func (h *AgentHandler) GetCommands(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
// Recover stuck commands with row-level lock
|
||||
// Recover stuck commands with row-level lock (excludes 'received' — migration 033)
|
||||
stuckCommands, err := h.commandQueries.GetStuckCommandsTx(cmdTx, agentID, 5*time.Minute)
|
||||
if err != nil {
|
||||
log.Printf("[WARNING] [server] [command] get_stuck_failed agent_id=%s error=%v", agentID, err)
|
||||
|
|
@ -806,9 +836,10 @@ func (h *AgentHandler) GetCommands(c *gin.Context) {
|
|||
}
|
||||
|
||||
response := models.CommandsResponse{
|
||||
Commands: commandItems,
|
||||
RapidPolling: rapidPolling,
|
||||
AcknowledgedIDs: acknowledgedIDs,
|
||||
Commands: commandItems,
|
||||
RapidPolling: rapidPolling,
|
||||
AcknowledgedIDs: acknowledgedIDs,
|
||||
ReceiptConfirmedIDs: receiptConfirmedIDs,
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
|
|
|
|||
|
|
@ -284,9 +284,14 @@ func (h *UnifiedUpdateHandler) ReportLog(c *gin.Context) {
|
|||
log.Printf("DEBUG: ReportLog - Marking command %s as completed for agent %s", commandID, agentID)
|
||||
|
||||
|
||||
// Surface MarkCommand* errors via structured ERROR log + should_retry hint
|
||||
// (ETHOS #1: errors are history). Without this, agent silently believes the
|
||||
// result was recorded while the server state stays inconsistent.
|
||||
var markErr error
|
||||
if req.Result == "success" || req.Result == "completed" {
|
||||
if err := h.commandQueries.MarkCommandCompleted(commandID, result); err != nil {
|
||||
log.Printf("Warning: Failed to mark command %s as completed: %v\n", commandID, err)
|
||||
if markErr = h.commandQueries.MarkCommandCompleted(commandID, result); markErr != nil {
|
||||
log.Printf("[ERROR] [server] [update_handler] mark_completed_failed agent_id=%s command_id=%s error=%q",
|
||||
agentID, commandID, markErr)
|
||||
}
|
||||
|
||||
command, err := h.commandQueries.GetCommandByID(commandID)
|
||||
|
|
@ -301,7 +306,7 @@ func (h *UnifiedUpdateHandler) ReportLog(c *gin.Context) {
|
|||
}
|
||||
|
||||
if err := h.updateQueries.UpdatePackageStatus(agentID, packageType, packageName, "updated", nil, completionTime); err != nil {
|
||||
log.Printf("Warning: Failed to update package status for %s/%s: %v", packageType, packageName, err)
|
||||
log.Printf("[WARNING] [server] [update_handler] package_status_update_failed package=%s type=%s error=%v", packageName, packageType, err)
|
||||
} else {
|
||||
log.Printf("[INFO] [server] [updates] package_updated package=%s type=%s", packageName, packageType)
|
||||
}
|
||||
|
|
@ -309,14 +314,25 @@ func (h *UnifiedUpdateHandler) ReportLog(c *gin.Context) {
|
|||
}
|
||||
}
|
||||
} else if req.Result == "failed" || req.Result == "failure" || req.Result == "dry_run_failed" || req.Result == "partial_failure" {
|
||||
if err := h.commandQueries.MarkCommandFailed(commandID, result); err != nil {
|
||||
log.Printf("Warning: Failed to mark command %s as failed: %v\n", commandID, err)
|
||||
if markErr = h.commandQueries.MarkCommandFailed(commandID, result); markErr != nil {
|
||||
log.Printf("[ERROR] [server] [update_handler] mark_failed_failed agent_id=%s command_id=%s error=%q",
|
||||
agentID, commandID, markErr)
|
||||
}
|
||||
} else {
|
||||
if err := h.commandQueries.UpdateCommandResult(commandID, result); err != nil {
|
||||
log.Printf("Warning: Failed to update command %s result: %v\n", commandID, err)
|
||||
if markErr = h.commandQueries.UpdateCommandResult(commandID, result); markErr != nil {
|
||||
log.Printf("[ERROR] [server] [update_handler] update_result_failed agent_id=%s command_id=%s error=%q",
|
||||
agentID, commandID, markErr)
|
||||
}
|
||||
}
|
||||
|
||||
if markErr != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "log saved but command state update failed",
|
||||
"should_retry": true,
|
||||
"reason": markErr.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -318,10 +318,15 @@ func (h *UpdateHandler) ReportLog(c *gin.Context) {
|
|||
"logged_at": time.Now(),
|
||||
}
|
||||
|
||||
// Update command status based on log result
|
||||
// Update command status based on log result.
|
||||
// MarkCommand* errors are ETHOS #1 violations if dropped silently — the
|
||||
// agent thinks the result was recorded and won't resend. Surface via
|
||||
// structured ERROR log + a should_retry hint in the response.
|
||||
var markErr error
|
||||
if req.Result == "success" || req.Result == "completed" {
|
||||
if err := h.commandQueries.MarkCommandCompleted(commandID, result); err != nil {
|
||||
log.Printf("[WARNING] [server] [updates] mark_completed_failed command_id=%s error=%v", commandID, err)
|
||||
if markErr = h.commandQueries.MarkCommandCompleted(commandID, result); markErr != nil {
|
||||
log.Printf("[ERROR] [server] [updates] mark_completed_failed agent_id=%s command_id=%s error=%q",
|
||||
agentID, commandID, markErr)
|
||||
}
|
||||
|
||||
// NEW: If this was a successful confirm_dependencies command, mark the package as updated
|
||||
|
|
@ -348,15 +353,26 @@ func (h *UpdateHandler) ReportLog(c *gin.Context) {
|
|||
}
|
||||
}
|
||||
} else if req.Result == "failed" || req.Result == "failure" || req.Result == "dry_run_failed" || req.Result == "partial_failure" {
|
||||
if err := h.commandQueries.MarkCommandFailed(commandID, result); err != nil {
|
||||
log.Printf("[WARNING] [server] [updates] mark_failed_failed command_id=%s error=%v", commandID, err)
|
||||
if markErr = h.commandQueries.MarkCommandFailed(commandID, result); markErr != nil {
|
||||
log.Printf("[ERROR] [server] [updates] mark_failed_failed agent_id=%s command_id=%s error=%q",
|
||||
agentID, commandID, markErr)
|
||||
}
|
||||
} else {
|
||||
// For other results, just update the result field
|
||||
if err := h.commandQueries.UpdateCommandResult(commandID, result); err != nil {
|
||||
log.Printf("[WARNING] [server] [updates] update_result_failed command_id=%s error=%v", commandID, err)
|
||||
if markErr = h.commandQueries.UpdateCommandResult(commandID, result); markErr != nil {
|
||||
log.Printf("[ERROR] [server] [updates] update_result_failed agent_id=%s command_id=%s error=%q",
|
||||
agentID, commandID, markErr)
|
||||
}
|
||||
}
|
||||
|
||||
if markErr != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "log saved but command state update failed",
|
||||
"should_retry": true,
|
||||
"reason": markErr.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import (
|
|||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
|
|
@ -217,29 +216,17 @@ func validateUpdateNonceMiddleware(nonceB64, serverPublicKey string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// isVersionUpgrade returns true when `new` is strictly newer than `current`.
|
||||
// Uses utils.IsNewerVersion, which is the project's semver-aware comparator (handles
|
||||
// arbitrary part counts including 4-part versions like 0.2.0.1). The prior
|
||||
// hand-rolled implementation panicked on <3 parts and silently dropped parts beyond
|
||||
// index 2, false-rejecting legitimate upgrades.
|
||||
func isVersionUpgrade(new, current string) bool {
|
||||
// Parse semantic versions
|
||||
newParts := strings.Split(new, ".")
|
||||
curParts := strings.Split(current, ".")
|
||||
|
||||
// Convert to integers for comparison
|
||||
newMajor, _ := strconv.Atoi(newParts[0])
|
||||
newMinor, _ := strconv.Atoi(newParts[1])
|
||||
newPatch, _ := strconv.Atoi(newParts[2])
|
||||
|
||||
curMajor, _ := strconv.Atoi(curParts[0])
|
||||
curMinor, _ := strconv.Atoi(curParts[1])
|
||||
curPatch, _ := strconv.Atoi(curParts[2])
|
||||
|
||||
// Check if new > current (not equal, not less)
|
||||
if newMajor > curMajor {
|
||||
return true
|
||||
// Reject empty inputs explicitly — nothing is an upgrade from missing data.
|
||||
if new == "" || current == "" {
|
||||
return false
|
||||
}
|
||||
if newMajor == curMajor && newMinor > curMinor {
|
||||
return true
|
||||
}
|
||||
if newMajor == curMajor && newMinor == curMinor && newPatch > curPatch {
|
||||
return true
|
||||
}
|
||||
return false // Equal or downgrade
|
||||
// Also tolerate a leading "v" by stripping it before comparison; the rest of
|
||||
// the codebase sometimes carries it on tag-derived strings.
|
||||
return utils.IsNewerVersion(strings.TrimPrefix(new, "v"), strings.TrimPrefix(current, "v"))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
-- Down for migration 033
|
||||
|
||||
DROP INDEX IF EXISTS idx_agent_commands_status_received_at;
|
||||
|
||||
-- Any rows currently in 'received' must be folded back to 'sent' before the
|
||||
-- CHECK constraint can be tightened, otherwise the ALTER will fail.
|
||||
UPDATE agent_commands SET status = 'sent' WHERE status = 'received';
|
||||
|
||||
ALTER TABLE agent_commands DROP CONSTRAINT IF EXISTS agent_commands_status_check;
|
||||
ALTER TABLE agent_commands ADD CONSTRAINT agent_commands_status_check
|
||||
CHECK (status IN ('pending', 'sent', 'running', 'completed', 'failed', 'timed_out', 'cancelled', 'archived_failed'));
|
||||
|
||||
ALTER TABLE agent_commands DROP COLUMN IF EXISTS received_at;
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
-- Migration 033: Add 'received' state to agent command lifecycle
|
||||
-- Distinguishes "server sent" (status=sent) from "agent confirmed receipt" (status=received).
|
||||
-- Closes the gap where stuck-command re-issuance fires blindly because the server has
|
||||
-- no way to tell a lost-in-flight command from a slow-to-execute one.
|
||||
-- Doctrine: TODO-full-command-lifecycle.md §1.
|
||||
|
||||
-- Drop existing CHECK constraint and re-add with 'received' included
|
||||
ALTER TABLE agent_commands DROP CONSTRAINT IF EXISTS agent_commands_status_check;
|
||||
ALTER TABLE agent_commands ADD CONSTRAINT agent_commands_status_check
|
||||
CHECK (status IN ('pending', 'sent', 'received', 'running', 'completed', 'failed', 'timed_out', 'cancelled', 'archived_failed'));
|
||||
|
||||
-- Timestamp the sent->received transition
|
||||
ALTER TABLE agent_commands
|
||||
ADD COLUMN IF NOT EXISTS received_at TIMESTAMPTZ;
|
||||
|
||||
-- Index for the TimeoutService 'received' reconciler (longer threshold than 'sent')
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_commands_status_received_at
|
||||
ON agent_commands(status, received_at)
|
||||
WHERE status = 'received';
|
||||
|
|
@ -338,6 +338,45 @@ func (q *AgentQueries) UpdateAgentUpdatingStatus(id uuid.UUID, isUpdating bool,
|
|||
return err
|
||||
}
|
||||
|
||||
// GetAgentsStuckUpdating returns agents where is_updating has been true longer than the
|
||||
// threshold. Used by TimeoutService to reconcile updates whose completion attestation
|
||||
// never arrived. Returns the candidates; the caller decides success vs timeout based on
|
||||
// whether current_version matches updating_to_version.
|
||||
//
|
||||
// Doctrine: TODO-full-command-lifecycle.md §4 (Timeout State Machine for update lifecycle).
|
||||
func (q *AgentQueries) GetAgentsStuckUpdating(threshold time.Time) ([]models.Agent, error) {
|
||||
var agents []models.Agent
|
||||
query := `
|
||||
SELECT id, hostname, os_type, os_version, os_architecture, agent_version,
|
||||
current_version, update_available, last_version_check, machine_id,
|
||||
public_key_fingerprint, is_updating, updating_to_version,
|
||||
update_initiated_at, last_seen, status, metadata, reboot_required,
|
||||
last_reboot_at, reboot_reason, created_at, updated_at
|
||||
FROM agents
|
||||
WHERE is_updating = true
|
||||
AND update_initiated_at IS NOT NULL
|
||||
AND update_initiated_at < $1
|
||||
`
|
||||
err := q.db.Select(&agents, query, threshold)
|
||||
return agents, err
|
||||
}
|
||||
|
||||
// ClearAgentUpdating flips is_updating=false WITHOUT changing current_version. Used
|
||||
// by TimeoutService when an update times out without a version-attestation match;
|
||||
// the operator can re-trigger from the dashboard.
|
||||
func (q *AgentQueries) ClearAgentUpdating(agentID uuid.UUID) error {
|
||||
query := `
|
||||
UPDATE agents
|
||||
SET is_updating = false,
|
||||
updating_to_version = NULL,
|
||||
update_initiated_at = NULL,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
`
|
||||
_, err := q.db.Exec(query, agentID)
|
||||
return err
|
||||
}
|
||||
|
||||
// CompleteAgentUpdate marks an agent update as successful and updates version
|
||||
func (q *AgentQueries) CompleteAgentUpdate(agentID string, newVersion string) error {
|
||||
query := `
|
||||
|
|
|
|||
|
|
@ -158,8 +158,11 @@ func (q *CommandQueries) RedeliverStuckCommandTx(tx *sqlx.Tx, id uuid.UUID) erro
|
|||
return err
|
||||
}
|
||||
|
||||
// GetStuckCommandsTx retrieves stuck commands with FOR UPDATE SKIP LOCKED (F-B2-2 fix)
|
||||
// Excludes commands that have exceeded max retries (F-B2-10 fix)
|
||||
// GetStuckCommandsTx retrieves stuck commands with FOR UPDATE SKIP LOCKED (F-B2-2 fix).
|
||||
// Excludes commands that have exceeded max retries (F-B2-10 fix).
|
||||
// Migration 033: 'received' commands are NOT re-issued here — the agent has them and
|
||||
// is working, just not yet done. Use GetStuckReceivedCommandsTx with a longer threshold
|
||||
// for that case (handled by TimeoutService, not the per-poll re-issuer).
|
||||
func (q *CommandQueries) GetStuckCommandsTx(tx *sqlx.Tx, agentID uuid.UUID, olderThan time.Duration) ([]models.AgentCommand, error) {
|
||||
var commands []models.AgentCommand
|
||||
query := `
|
||||
|
|
@ -179,6 +182,72 @@ func (q *CommandQueries) GetStuckCommandsTx(tx *sqlx.Tx, agentID uuid.UUID, olde
|
|||
return commands, err
|
||||
}
|
||||
|
||||
// MarkCommandsReceivedTx batch-transitions a set of command IDs from 'sent' to 'received'.
|
||||
// Only commands belonging to the named agent and currently in status 'sent' are flipped —
|
||||
// this prevents an agent from claiming receipt of another agent's commands, and prevents
|
||||
// re-transitioning out of terminal states. Returns the IDs that were actually flipped.
|
||||
//
|
||||
// Doctrine: TODO-full-command-lifecycle.md §2. Receipt confirmation closes the gap where
|
||||
// GetStuckCommandsTx would re-issue blindly.
|
||||
func (q *CommandQueries) MarkCommandsReceivedTx(tx *sqlx.Tx, agentID uuid.UUID, commandIDs []string) ([]string, error) {
|
||||
if len(commandIDs) == 0 {
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
parsed := make([]uuid.UUID, 0, len(commandIDs))
|
||||
for _, idStr := range commandIDs {
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
parsed = append(parsed, id)
|
||||
}
|
||||
if len(parsed) == 0 {
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
placeholders := make([]string, len(parsed))
|
||||
args := make([]interface{}, 0, len(parsed)+1)
|
||||
args = append(args, agentID)
|
||||
for i, id := range parsed {
|
||||
placeholders[i] = fmt.Sprintf("$%d", i+2)
|
||||
args = append(args, id)
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
UPDATE agent_commands
|
||||
SET status = 'received', received_at = NOW()
|
||||
WHERE agent_id = $1
|
||||
AND status = 'sent'
|
||||
AND id IN (%s)
|
||||
RETURNING id::text
|
||||
`, strings.Join(placeholders, ","))
|
||||
|
||||
var confirmed []string
|
||||
if err := tx.Select(&confirmed, query, args...); err != nil {
|
||||
return nil, fmt.Errorf("mark_received batch failed: %w", err)
|
||||
}
|
||||
return confirmed, nil
|
||||
}
|
||||
|
||||
// GetStuckReceivedCommandsTx returns commands that have been 'received' by an agent
|
||||
// but haven't transitioned to completed/failed within the longer threshold. These are
|
||||
// timed out by the TimeoutService rather than re-issued — the agent had it, the agent
|
||||
// is presumed broken or the command is presumed lost on the agent side.
|
||||
func (q *CommandQueries) GetStuckReceivedCommandsTx(tx *sqlx.Tx, olderThan time.Duration) ([]models.AgentCommand, error) {
|
||||
var commands []models.AgentCommand
|
||||
query := `
|
||||
SELECT * FROM agent_commands
|
||||
WHERE status = 'received'
|
||||
AND received_at IS NOT NULL
|
||||
AND received_at < $1
|
||||
ORDER BY received_at ASC
|
||||
FOR UPDATE SKIP LOCKED
|
||||
`
|
||||
err := tx.Select(&commands, query, time.Now().Add(-olderThan))
|
||||
return commands, err
|
||||
}
|
||||
|
||||
// MarkCommandCompleted updates a command's status to completed
|
||||
func (q *CommandQueries) MarkCommandCompleted(id uuid.UUID, result models.JSONB) error {
|
||||
now := time.Now()
|
||||
|
|
@ -514,8 +583,9 @@ func (q *CommandQueries) GetCommandsInTimeRange(hours int) (int, error) {
|
|||
return count, err
|
||||
}
|
||||
|
||||
// GetStuckCommands retrieves commands that are stuck in 'pending' or 'sent' status
|
||||
// Excludes expired commands and commands that have exceeded max retries (F-B2-10 fix)
|
||||
// GetStuckCommands retrieves commands stuck in 'pending' or 'sent' status.
|
||||
// Excludes expired commands and commands that have exceeded max retries (F-B2-10 fix).
|
||||
// 'received' is excluded — see GetStuckCommandsTx for rationale.
|
||||
func (q *CommandQueries) GetStuckCommands(agentID uuid.UUID, olderThan time.Duration) ([]models.AgentCommand, error) {
|
||||
var commands []models.AgentCommand
|
||||
query := `
|
||||
|
|
|
|||
|
|
@ -0,0 +1,150 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// AgentCommand represents a command to be executed by an agent
|
||||
type AgentCommand struct {
|
||||
ID uuid.UUID `json:"id" db:"id"`
|
||||
AgentID uuid.UUID `json:"agent_id" db:"agent_id"`
|
||||
CommandType string `json:"command_type" db:"command_type"`
|
||||
Params JSONB `json:"params" db:"params"`
|
||||
Status string `json:"status" db:"status"`
|
||||
Source string `json:"source" db:"source"`
|
||||
Signature string `json:"signature,omitempty" db:"signature"`
|
||||
KeyID string `json:"key_id,omitempty" db:"key_id"`
|
||||
SignedAt *time.Time `json:"signed_at,omitempty" db:"signed_at"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty" db:"expires_at"`
|
||||
IdempotencyKey *string `json:"idempotency_key,omitempty" db:"idempotency_key"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
SentAt *time.Time `json:"sent_at,omitempty" db:"sent_at"`
|
||||
ReceivedAt *time.Time `json:"received_at,omitempty" db:"received_at"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty" db:"completed_at"`
|
||||
Result JSONB `json:"result,omitempty" db:"result"`
|
||||
RetriedFromID *uuid.UUID `json:"retried_from_id,omitempty" db:"retried_from_id"`
|
||||
RetryCount int `json:"retry_count" db:"retry_count"`
|
||||
}
|
||||
|
||||
// Validate checks if the command has all required fields
|
||||
func (c *AgentCommand) Validate() error {
|
||||
if c.ID == uuid.Nil {
|
||||
return ErrCommandIDRequired
|
||||
}
|
||||
if c.AgentID == uuid.Nil {
|
||||
return ErrAgentIDRequired
|
||||
}
|
||||
if c.CommandType == "" {
|
||||
return ErrCommandTypeRequired
|
||||
}
|
||||
if c.Status == "" {
|
||||
return ErrStatusRequired
|
||||
}
|
||||
if c.Source != "manual" && c.Source != "system" {
|
||||
return ErrInvalidSource
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsTerminal returns true if the command is in a terminal state
|
||||
func (c *AgentCommand) IsTerminal() bool {
|
||||
return c.Status == "completed" || c.Status == "failed" || c.Status == "cancelled"
|
||||
}
|
||||
|
||||
// CanRetry returns true if the command can be retried
|
||||
func (c *AgentCommand) CanRetry() bool {
|
||||
return c.Status == "failed" && c.RetriedFromID == nil
|
||||
}
|
||||
|
||||
// Predefined errors for validation
|
||||
var (
|
||||
ErrCommandIDRequired = errors.New("command ID cannot be zero UUID")
|
||||
ErrAgentIDRequired = errors.New("agent ID is required")
|
||||
ErrCommandTypeRequired = errors.New("command type is required")
|
||||
ErrStatusRequired = errors.New("status is required")
|
||||
ErrInvalidSource = errors.New("source must be 'manual' or 'system'")
|
||||
)
|
||||
|
||||
// CommandsResponse is returned when an agent checks in for commands
|
||||
type CommandsResponse struct {
|
||||
Commands []CommandItem `json:"commands"`
|
||||
RapidPolling *RapidPollingConfig `json:"rapid_polling,omitempty"`
|
||||
AcknowledgedIDs []string `json:"acknowledged_ids,omitempty"` // Result IDs server has recorded
|
||||
ReceiptConfirmedIDs []string `json:"receipt_confirmed_ids,omitempty"` // Command IDs server transitioned sent->received this turn
|
||||
}
|
||||
|
||||
// RapidPollingConfig contains rapid polling configuration for the agent
|
||||
type RapidPollingConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Until string `json:"until"` // ISO 8601 timestamp
|
||||
}
|
||||
|
||||
// CommandItem represents a command in the response
|
||||
type CommandItem struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Params JSONB `json:"params"`
|
||||
Signature string `json:"signature,omitempty"`
|
||||
KeyID string `json:"key_id,omitempty"`
|
||||
SignedAt *time.Time `json:"signed_at,omitempty"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
AgentID string `json:"agent_id,omitempty"`
|
||||
CreatedAt *time.Time `json:"created_at,omitempty"`
|
||||
}
|
||||
|
||||
// Command types
|
||||
const (
|
||||
CommandTypeCollectSpecs = "collect_specs"
|
||||
CommandTypeInstallUpdate = "install_updates"
|
||||
CommandTypeDryRunUpdate = "dry_run_update"
|
||||
CommandTypeConfirmDependencies = "confirm_dependencies"
|
||||
CommandTypeRollback = "rollback_update"
|
||||
CommandTypeUpdateAgent = "update_agent"
|
||||
CommandTypeEnableHeartbeat = "enable_heartbeat"
|
||||
CommandTypeDisableHeartbeat = "disable_heartbeat"
|
||||
CommandTypeReboot = "reboot"
|
||||
)
|
||||
|
||||
// Command statuses
|
||||
const (
|
||||
CommandStatusPending = "pending"
|
||||
CommandStatusSent = "sent"
|
||||
CommandStatusReceived = "received" // Agent confirmed receipt; not yet completed
|
||||
CommandStatusCompleted = "completed"
|
||||
CommandStatusFailed = "failed"
|
||||
CommandStatusTimedOut = "timed_out"
|
||||
CommandStatusCancelled = "cancelled"
|
||||
CommandStatusRunning = "running"
|
||||
)
|
||||
|
||||
// Command sources
|
||||
const (
|
||||
CommandSourceManual = "manual" // User-initiated via UI
|
||||
CommandSourceSystem = "system" // Auto-triggered by system operations
|
||||
)
|
||||
|
||||
// ActiveCommandInfo represents information about an active command for UI display
|
||||
type ActiveCommandInfo struct {
|
||||
ID uuid.UUID `json:"id" db:"id"`
|
||||
AgentID uuid.UUID `json:"agent_id" db:"agent_id"`
|
||||
CommandType string `json:"command_type" db:"command_type"`
|
||||
Params JSONB `json:"params" db:"params"`
|
||||
Status string `json:"status" db:"status"`
|
||||
Source string `json:"source" db:"source"`
|
||||
Signature string `json:"signature,omitempty" db:"signature"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
SentAt *time.Time `json:"sent_at,omitempty" db:"sent_at"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty" db:"completed_at"`
|
||||
Result JSONB `json:"result,omitempty" db:"result"`
|
||||
AgentHostname string `json:"agent_hostname" db:"agent_hostname"`
|
||||
PackageName string `json:"package_name" db:"package_name"`
|
||||
PackageType string `json:"package_type" db:"package_type"`
|
||||
RetriedFromID *uuid.UUID `json:"retried_from_id,omitempty" db:"retried_from_id"`
|
||||
IsRetry bool `json:"is_retry" db:"is_retry"`
|
||||
HasBeenRetried bool `json:"has_been_retried" db:"has_been_retried"`
|
||||
RetryCount int `json:"retry_count" db:"retry_count"`
|
||||
}
|
||||
|
|
@ -12,40 +12,54 @@ import (
|
|||
|
||||
// TimeoutService handles timeout management for long-running operations
|
||||
type TimeoutService struct {
|
||||
commandQueries *queries.CommandQueries
|
||||
updateQueries *queries.UpdateQueries
|
||||
ticker *time.Ticker
|
||||
stopChan chan bool
|
||||
sentTimeout time.Duration // For commands already sent to agents
|
||||
pendingTimeout time.Duration // For commands stuck in queue
|
||||
checkInterval time.Duration // How often to check for timeouts
|
||||
commandQueries *queries.CommandQueries
|
||||
updateQueries *queries.UpdateQueries
|
||||
agentQueries *queries.AgentQueries
|
||||
ticker *time.Ticker
|
||||
stopChan chan bool
|
||||
sentTimeout time.Duration // For commands already sent to agents
|
||||
pendingTimeout time.Duration // For commands stuck in queue
|
||||
receivedTimeout time.Duration // For commands received by agent but not completed (Migration 033 §4)
|
||||
updateTimeout time.Duration // For agents stuck in is_updating=true
|
||||
checkInterval time.Duration // How often to check for timeouts
|
||||
}
|
||||
|
||||
// NewTimeoutService creates a new timeout service with configurable durations.
|
||||
// Pass zero values to use defaults (2h sent, 30m pending, 5m check interval).
|
||||
func NewTimeoutService(cq *queries.CommandQueries, uq *queries.UpdateQueries, sentTimeout, pendingTimeout, checkInterval time.Duration) *TimeoutService {
|
||||
// Pass zero values to use defaults (2h sent, 30m pending, 30m received, 15m update,
|
||||
// 5m check interval).
|
||||
func NewTimeoutService(cq *queries.CommandQueries, uq *queries.UpdateQueries, aq *queries.AgentQueries, sentTimeout, pendingTimeout, receivedTimeout, updateTimeout, checkInterval time.Duration) *TimeoutService {
|
||||
if sentTimeout <= 0 {
|
||||
sentTimeout = 2 * time.Hour
|
||||
}
|
||||
if pendingTimeout <= 0 {
|
||||
pendingTimeout = 30 * time.Minute
|
||||
}
|
||||
if receivedTimeout <= 0 {
|
||||
receivedTimeout = 30 * time.Minute
|
||||
}
|
||||
if updateTimeout <= 0 {
|
||||
updateTimeout = 15 * time.Minute
|
||||
}
|
||||
if checkInterval <= 0 {
|
||||
checkInterval = 5 * time.Minute
|
||||
}
|
||||
return &TimeoutService{
|
||||
commandQueries: cq,
|
||||
updateQueries: uq,
|
||||
sentTimeout: sentTimeout,
|
||||
pendingTimeout: pendingTimeout,
|
||||
checkInterval: checkInterval,
|
||||
stopChan: make(chan bool),
|
||||
commandQueries: cq,
|
||||
updateQueries: uq,
|
||||
agentQueries: aq,
|
||||
sentTimeout: sentTimeout,
|
||||
pendingTimeout: pendingTimeout,
|
||||
receivedTimeout: receivedTimeout,
|
||||
updateTimeout: updateTimeout,
|
||||
checkInterval: checkInterval,
|
||||
stopChan: make(chan bool),
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins the timeout monitoring service
|
||||
func (ts *TimeoutService) Start() {
|
||||
log.Printf("[INFO] [server] [timeout] service_started sent_timeout=%v pending_timeout=%v check_interval=%v", ts.sentTimeout, ts.pendingTimeout, ts.checkInterval)
|
||||
log.Printf("[INFO] [server] [timeout] service_started sent_timeout=%v pending_timeout=%v received_timeout=%v update_timeout=%v check_interval=%v",
|
||||
ts.sentTimeout, ts.pendingTimeout, ts.receivedTimeout, ts.updateTimeout, ts.checkInterval)
|
||||
|
||||
ts.ticker = time.NewTicker(ts.checkInterval)
|
||||
|
||||
|
|
@ -54,6 +68,8 @@ func (ts *TimeoutService) Start() {
|
|||
select {
|
||||
case <-ts.ticker.C:
|
||||
ts.checkForTimeouts()
|
||||
ts.checkForReceivedTimeouts()
|
||||
ts.reconcileAgentUpdates()
|
||||
case <-ts.stopChan:
|
||||
ts.ticker.Stop()
|
||||
log.Println("Timeout service stopped")
|
||||
|
|
@ -268,4 +284,126 @@ func (ts *TimeoutService) SetSentTimeout(duration time.Duration) {
|
|||
func (ts *TimeoutService) SetPendingTimeout(duration time.Duration) {
|
||||
ts.pendingTimeout = duration
|
||||
log.Printf("Pending timeout duration updated to %v", duration)
|
||||
}
|
||||
|
||||
// checkForReceivedTimeouts handles commands the agent received but never completed.
|
||||
// Distinct from sent-timeouts because we know the agent had it — re-issuance won't
|
||||
// help; the right action is to mark timed_out so the operator (or scheduler) can
|
||||
// decide whether to retry or escalate.
|
||||
//
|
||||
// Doctrine: TODO-full-command-lifecycle.md §4. Migration 033 added the 'received' state.
|
||||
func (ts *TimeoutService) checkForReceivedTimeouts() {
|
||||
tx, err := ts.commandQueries.DB().Beginx()
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] [server] [timeout] received_tx_begin_failed error=%v", err)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
stuck, err := ts.commandQueries.GetStuckReceivedCommandsTx(tx, ts.receivedTimeout)
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] [server] [timeout] get_stuck_received_failed error=%v", err)
|
||||
return
|
||||
}
|
||||
if len(stuck) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
for _, command := range stuck {
|
||||
cmd := command
|
||||
if err := ts.timeoutCommand(&cmd); err != nil {
|
||||
log.Printf("[ERROR] [server] [timeout] timeout_received_failed command_id=%s error=%v", cmd.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
log.Printf("[ERROR] [server] [timeout] received_tx_commit_failed error=%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[INFO] [server] [timeout] received_commands_timed_out count=%d threshold=%v", len(stuck), ts.receivedTimeout)
|
||||
}
|
||||
|
||||
// reconcileAgentUpdates handles agents whose is_updating flag was set but never cleared.
|
||||
// Decision per agent:
|
||||
// - if current_version == updating_to_version: the agent completed the update and reported
|
||||
// its new version, but the per-request side-effect to clear is_updating was deliberately
|
||||
// omitted. Clear it now (success path).
|
||||
// - otherwise: the update never reported in (binary failed to start, network dead, etc).
|
||||
// Clear is_updating and log a timeout system_event so the operator sees what happened.
|
||||
//
|
||||
// Doctrine: TODO-full-command-lifecycle.md §4 + audit finding "completion loop has no firing path".
|
||||
func (ts *TimeoutService) reconcileAgentUpdates() {
|
||||
if ts.agentQueries == nil {
|
||||
return // not wired (test path)
|
||||
}
|
||||
threshold := time.Now().Add(-ts.updateTimeout)
|
||||
stuck, err := ts.agentQueries.GetAgentsStuckUpdating(threshold)
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] [server] [timeout] get_stuck_updating_agents_failed error=%v", err)
|
||||
return
|
||||
}
|
||||
if len(stuck) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
successes, timeouts := 0, 0
|
||||
for _, agent := range stuck {
|
||||
target := ""
|
||||
if agent.UpdatingToVersion != nil {
|
||||
target = *agent.UpdatingToVersion
|
||||
}
|
||||
|
||||
if target != "" && agent.CurrentVersion == target {
|
||||
// Success — agent reported the new version; just close the flag.
|
||||
if err := ts.agentQueries.CompleteAgentUpdate(agent.ID.String(), agent.CurrentVersion); err != nil {
|
||||
log.Printf("[ERROR] [server] [timeout] complete_update_failed agent_id=%s error=%v", agent.ID, err)
|
||||
continue
|
||||
}
|
||||
ts.recordUpdateEvent(agent.ID, "succeeded", "info",
|
||||
fmt.Sprintf("Agent update succeeded: %s reported", agent.CurrentVersion),
|
||||
map[string]interface{}{"new_version": agent.CurrentVersion, "reconciled_by": "timeout_service"})
|
||||
successes++
|
||||
continue
|
||||
}
|
||||
|
||||
// Timeout — version never matched. Clear the flag so the operator can retry.
|
||||
if err := ts.agentQueries.ClearAgentUpdating(agent.ID); err != nil {
|
||||
log.Printf("[ERROR] [server] [timeout] clear_updating_failed agent_id=%s error=%v", agent.ID, err)
|
||||
continue
|
||||
}
|
||||
ageMessage := fmt.Sprintf("Agent update timed out after %v without version attestation (current=%s, target=%s)",
|
||||
ts.updateTimeout, agent.CurrentVersion, target)
|
||||
ts.recordUpdateEvent(agent.ID, "timed_out", "warning", ageMessage,
|
||||
map[string]interface{}{
|
||||
"current_version": agent.CurrentVersion,
|
||||
"target_version": target,
|
||||
"threshold": ts.updateTimeout.String(),
|
||||
"reconciled_by": "timeout_service",
|
||||
})
|
||||
timeouts++
|
||||
}
|
||||
|
||||
log.Printf("[INFO] [server] [timeout] agent_updates_reconciled stuck=%d succeeded=%d timed_out=%d threshold=%v",
|
||||
len(stuck), successes, timeouts, ts.updateTimeout)
|
||||
}
|
||||
|
||||
// recordUpdateEvent writes a system_event row for an update lifecycle transition. Best
|
||||
// effort — a failure to log shouldn't block the reconciler from continuing on the next agent.
|
||||
func (ts *TimeoutService) recordUpdateEvent(agentID uuid.UUID, subtype, severity, message string, metadata map[string]interface{}) {
|
||||
event := &models.SystemEvent{
|
||||
ID: uuid.New(),
|
||||
AgentID: &agentID,
|
||||
EventType: "agent_update",
|
||||
EventSubtype: subtype,
|
||||
Severity: severity,
|
||||
Component: "agent",
|
||||
Message: message,
|
||||
Metadata: metadata,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
if err := ts.agentQueries.CreateSystemEvent(event); err != nil {
|
||||
log.Printf("[WARNING] [server] [timeout] system_event_write_failed agent_id=%s subtype=%s error=%v",
|
||||
agentID, subtype, err)
|
||||
}
|
||||
}
|
||||
|
|
@ -14,7 +14,7 @@ func TestTimeoutServiceUsesConfiguredValues(t *testing.T) {
|
|||
customPending := 45 * time.Minute
|
||||
customInterval := 10 * time.Minute
|
||||
|
||||
ts := services.NewTimeoutService(nil, nil, customSent, customPending, customInterval)
|
||||
ts := services.NewTimeoutService(nil, nil, nil, customSent, customPending, 20*time.Minute, 10*time.Minute, customInterval)
|
||||
if ts == nil {
|
||||
t.Fatal("NewTimeoutService returned nil")
|
||||
}
|
||||
|
|
@ -29,7 +29,7 @@ func TestTimeoutServiceUsesConfiguredValues(t *testing.T) {
|
|||
// are replaced with sensible defaults, not left at zero.
|
||||
func TestTimeoutServiceFallsBackToDefaults(t *testing.T) {
|
||||
// Passing zero values should result in defaults being used (not panic/zero tickers)
|
||||
ts := services.NewTimeoutService(nil, nil, 0, 0, 0)
|
||||
ts := services.NewTimeoutService(nil, nil, nil, 0, 0, 0, 0, 0)
|
||||
if ts == nil {
|
||||
t.Fatal("NewTimeoutService returned nil with zero-value durations")
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue