Files
pulse/internal/api/docker_agents.go
T
rcourtman 94f96eb043 Stop tenant monitor lookups falling back to the default org
The agent, alert, notification and system settings handlers resolved
the request organization's monitor through the tenant manager and, when
that lookup failed for any reason, quietly used the default
organization's monitor instead. A request for another organization
could then list, change or remove the default organization's agents,
alert configuration, webhooks and email settings.

The router's tenant guard already answers 503 tenant_unavailable when a
non-default organization's monitor cannot be resolved, so normal
requests never reached that fallback. The handlers still used it when
a lookup failed after the guard had passed (an organization being
deleted mid-request) and for callers outside the guarded chain. These
routes are open to organization owners, so the handlers have to fail
closed on their own.

Each getMonitor now falls back to the primary monitor only for the
default organization. Any other organization whose monitor cannot be
resolved gets nil, and the HTTP handlers resolve the monitor once
through requireMonitor, which answers the same 503 tenant_unavailable
as the router guard. Default-organization and single-tenant behaviour is
unchanged.

The configuration handlers' getContextState already refused non-default
organizations and is covered by an existing test.

Contract-Neutral: Handlers now return the router tenant guard's existing 503 tenant_unavailable response instead of the default org's data; no new API surface.
2026-10-17 10:14:21 +01:00

659 lines
22 KiB
Go

package api
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
"github.com/rcourtman/pulse-go-rewrite/internal/utils"
"github.com/rcourtman/pulse-go-rewrite/internal/websocket"
agentsdocker "github.com/rcourtman/pulse-go-rewrite/pkg/agents/docker"
"github.com/rs/zerolog/log"
)
// DockerAgentHandlers manages Docker / Podman module ingest from pulse-agent.
type DockerAgentHandlers struct {
baseAgentHandlers
config *config.Config
}
type dockerCommandAckRequest struct {
AgentID string `json:"agentId"`
Status string `json:"status"`
Message string `json:"message,omitempty"`
Payload map[string]any `json:"payload,omitempty"`
}
// errInvalidCommandStatus is returned when an unrecognized command status is provided.
var errInvalidCommandStatus = errors.New("invalid command status")
// normalizeCommandStatus converts a client-provided status string into a canonical
// internal status constant. It accepts multiple aliases for each status:
// - acknowledged: "", "ack", "acknowledged"
// - in_progress: "in_progress", "progress"
// - completed: "success", "completed", "complete"
// - failed: "fail", "failed", "error"
//
// Returns errInvalidCommandStatus for unrecognized values.
func normalizeCommandStatus(status string) (string, error) {
status = strings.ToLower(strings.TrimSpace(status))
switch status {
case "", "ack", "acknowledged":
return monitoring.DockerCommandStatusAcknowledged, nil
case "in_progress", "progress":
return monitoring.DockerCommandStatusInProgress, nil
case "success", "completed", "complete":
return monitoring.DockerCommandStatusCompleted, nil
case "fail", "failed", "error":
return monitoring.DockerCommandStatusFailed, nil
default:
return "", errInvalidCommandStatus
}
}
func trimDockerRuntimePathPrefix(path string) string {
if !strings.HasPrefix(path, "/api/agents/docker/runtimes/") {
return ""
}
return strings.TrimPrefix(path, "/api/agents/docker/runtimes/")
}
func dockerRuntimeAgentIDFromPath(path string, suffix string) string {
trimmed := trimDockerRuntimePathPrefix(path)
if suffix != "" {
trimmed = strings.TrimSuffix(trimmed, suffix)
}
return strings.TrimSpace(trimmed)
}
// NewDockerAgentHandlers constructs a new Docker / Podman module handler group.
func NewDockerAgentHandlers(mtm *monitoring.MultiTenantMonitor, m *monitoring.Monitor, hub *websocket.Hub, cfg *config.Config) *DockerAgentHandlers {
return &DockerAgentHandlers{baseAgentHandlers: newBaseAgentHandlers(mtm, m, hub), config: cfg}
}
// HandleReport accepts heartbeat payloads from the Docker / Podman module.
func (h *DockerAgentHandlers) HandleReport(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only POST is allowed", nil)
return
}
// The shared report contract separately caps the encoded HTTP body and the
// decoded JSON body. Exact boundary values are accepted.
r.Body = http.MaxBytesReader(w, r.Body, agentsdocker.ReportEncodedBodyLimitBytes)
defer r.Body.Close()
encodedBody, err := io.ReadAll(r.Body)
if err != nil {
var maxBytesErr *http.MaxBytesError
if errors.As(err, &maxBytesErr) {
writeDockerReportSizeError(w, &agentsdocker.ReportSizeError{
Dimension: agentsdocker.ReportSizeEncodedBody,
LimitBytes: agentsdocker.ReportEncodedBodyLimitBytes,
})
return
}
writeErrorResponse(w, http.StatusBadRequest, "invalid_body", "Failed to read request body", map[string]string{"error": err.Error()})
return
}
body, err := agentsdocker.DecodeReportBody(encodedBody, r.Header.Get("Content-Encoding"))
if err != nil {
var sizeErr *agentsdocker.ReportSizeError
var encodingErr *agentsdocker.UnsupportedReportContentEncodingError
switch {
case errors.As(err, &sizeErr):
writeDockerReportSizeError(w, sizeErr)
case errors.As(err, &encodingErr):
writeErrorResponse(w, http.StatusUnsupportedMediaType, "unsupported_encoding", encodingErr.Error(), nil)
default:
writeErrorResponse(w, http.StatusBadRequest, "invalid_compression", "Failed to decompress request body", map[string]string{"error": err.Error()})
}
return
}
var report agentsdocker.Report
if err := json.Unmarshal(body, &report); err != nil {
writeErrorResponse(w, http.StatusBadRequest, "invalid_json", "Failed to decode request body", map[string]string{"error": err.Error()})
return
}
if report.Timestamp.IsZero() {
report.Timestamp = time.Now()
}
tokenRecord := getAPITokenRecordFromRequest(r)
monitor := h.requireMonitor(w, r)
if monitor == nil {
return
}
host, err := monitor.ApplyDockerReport(report, tokenRecord)
if err != nil {
writeErrorResponse(w, http.StatusBadRequest, "invalid_report", err.Error(), nil)
return
}
log.Debug().
Str("dockerHost", host.Hostname).
Int("containers", len(host.Containers)).
Msg("Docker / Podman module report processed")
// Broadcast the updated state for near-real-time UI updates
h.broadcastState(r.Context())
response := map[string]any{
"success": true,
"agentId": host.ID,
"containers": len(host.Containers),
"lastSeen": host.LastSeen,
}
if payload, cmd := monitor.FetchDockerCommandForHost(host.ID); cmd != nil {
commandResponse := map[string]any{
"id": cmd.ID,
"type": cmd.Type,
}
if len(payload) > 0 {
commandResponse["payload"] = payload
}
response["commands"] = []map[string]any{commandResponse}
}
if err := utils.WriteJSONResponse(w, response); err != nil {
log.Error().Err(err).Msg("Failed to serialize Docker / Podman module response")
}
}
func writeDockerReportSizeError(w http.ResponseWriter, sizeErr *agentsdocker.ReportSizeError) {
details := map[string]string{
"dimension": string(sizeErr.Dimension),
"limitBytes": strconv.FormatInt(sizeErr.LimitBytes, 10),
}
if sizeErr.ActualBytes > 0 {
details["actualBytes"] = strconv.FormatInt(sizeErr.ActualBytes, 10)
}
writeErrorResponse(
w,
http.StatusRequestEntityTooLarge,
"report_too_large",
fmt.Sprintf(
"Docker / Podman report exceeds the server size limit (%s)",
agentsdocker.ReportSizeLimitDescription(),
),
details,
)
}
// HandleDockerHostActions routes docker host management actions based on path and method.
func (h *DockerAgentHandlers) HandleDockerHostActions(w http.ResponseWriter, r *http.Request) {
// Check if this is an allow reenroll request
if strings.HasSuffix(r.URL.Path, "/allow-reenroll") && r.Method == http.MethodPost {
h.HandleAllowReenroll(w, r)
return
}
// Check if this is an unhide request
if strings.HasSuffix(r.URL.Path, "/unhide") && r.Method == http.MethodPut {
h.HandleUnhideHost(w, r)
return
}
// Check if this is a pending uninstall request
if strings.HasSuffix(r.URL.Path, "/pending-uninstall") && r.Method == http.MethodPut {
h.HandleMarkPendingUninstall(w, r)
return
}
// Check if this is a custom display name update request
if strings.HasSuffix(r.URL.Path, "/display-name") && r.Method == http.MethodPut {
h.HandleSetCustomDisplayName(w, r)
return
}
// Check if this is a check updates request
if strings.HasSuffix(r.URL.Path, "/check-updates") && r.Method == http.MethodPost {
h.HandleCheckUpdates(w, r)
return
}
// Check if this is an update-all request
if strings.HasSuffix(r.URL.Path, "/update-all") && r.Method == http.MethodPost {
h.HandleUpdateAll(w, r)
return
}
// Otherwise, handle as delete/hide request
if r.Method == http.MethodDelete {
h.HandleDeleteHost(w, r)
return
}
writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed", nil)
}
// HandleCommandAck processes acknowledgements from Docker / Podman modules for issued commands.
func (h *DockerAgentHandlers) HandleCommandAck(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only POST is allowed", nil)
return
}
// Limit request body to 8KB to prevent memory exhaustion
r.Body = http.MaxBytesReader(w, r.Body, 8*1024)
trimmed := strings.TrimPrefix(r.URL.Path, "/api/agents/docker/commands/")
if !strings.HasSuffix(trimmed, "/ack") {
writeErrorResponse(w, http.StatusNotFound, "not_found", "Endpoint not found", nil)
return
}
commandID := strings.TrimSuffix(trimmed, "/ack")
commandID = strings.TrimSuffix(commandID, "/")
commandID = strings.TrimSpace(commandID)
if commandID == "" {
writeErrorResponse(w, http.StatusBadRequest, "missing_command_id", "Command ID is required", nil)
return
}
var req dockerCommandAckRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErrorResponse(w, http.StatusBadRequest, "invalid_json", "Failed to decode request body", map[string]string{"error": err.Error()})
return
}
status, err := normalizeCommandStatus(req.Status)
if err != nil {
writeErrorResponse(w, http.StatusBadRequest, "invalid_status", "Invalid command status", nil)
return
}
monitor := h.requireMonitor(w, r)
if monitor == nil {
return
}
agentID := strings.TrimSpace(req.AgentID)
if agentID == "" {
writeErrorResponse(w, http.StatusBadRequest, "missing_agent_id", "Agent ID is required", nil)
return
}
commandStatus, agentID, shouldRemove, err := monitor.AcknowledgeDockerHostCommand(commandID, agentID, status, req.Message)
if err != nil {
writeErrorResponse(w, http.StatusBadRequest, "docker_command_ack_failed", err.Error(), nil)
return
}
// If a container update succeeded, migrate persisted container metadata (custom URLs, notes, tags)
// so the UI doesn't lose it when the container ID changes.
if status == monitoring.DockerCommandStatusCompleted && commandStatus.Type == monitoring.DockerCommandTypeUpdateContainer && len(req.Payload) > 0 {
oldContainerID, _ := req.Payload["oldContainerId"].(string)
newContainerID, _ := req.Payload["newContainerId"].(string)
oldContainerID = strings.TrimSpace(oldContainerID)
newContainerID = strings.TrimSpace(newContainerID)
if oldContainerID != "" && newContainerID != "" && oldContainerID != newContainerID {
if err := monitor.CopyDockerContainerMetadata(agentID, oldContainerID, newContainerID); err != nil {
log.Warn().
Err(err).
Str("dockerAgentID", agentID).
Str("oldContainerID", oldContainerID).
Str("newContainerID", newContainerID).
Msg("Failed to migrate docker container metadata after update")
}
}
}
if shouldRemove {
if _, removeErr := monitor.RemoveDockerHost(agentID); removeErr != nil {
log.Error().Err(removeErr).Str("dockerAgentID", agentID).Str("commandID", commandID).Msg("Failed to remove docker host after command completion")
} else {
// Clear the removal block since the agent has confirmed it stopped successfully.
// This allows immediate re-enrollment without waiting for the 24-hour TTL.
if reenrollErr := monitor.AllowDockerHostReenroll(agentID); reenrollErr != nil {
log.Warn().Err(reenrollErr).Str("dockerAgentID", agentID).Msg("Failed to clear removal block after successful stop")
}
}
}
h.broadcastState(r.Context())
if err := utils.WriteJSONResponse(w, map[string]any{
"success": true,
"agentId": agentID,
"command": commandStatus,
}); err != nil {
log.Error().Err(err).Msg("Failed to serialize docker command acknowledgement response")
}
}
// HandleDeleteHost removes or hides a Docker / Podman host from the shared state.
// If query parameter ?hide=true is provided, the host is marked as hidden instead of deleted.
func (h *DockerAgentHandlers) HandleDeleteHost(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only DELETE is allowed", nil)
return
}
agentID := dockerRuntimeAgentIDFromPath(r.URL.Path, "")
if agentID == "" {
writeErrorResponse(w, http.StatusBadRequest, "missing_agent_id", "Agent ID is required", nil)
return
}
// Check if we should hide instead of delete
hideParam := r.URL.Query().Get("hide")
shouldHide := strings.ToLower(hideParam) == "true"
forceParam := strings.ToLower(r.URL.Query().Get("force"))
force := forceParam == "true" || strings.ToLower(r.URL.Query().Get("mode")) == "force"
monitor := h.requireMonitor(w, r)
if monitor == nil {
return
}
priorHost, hostExists := monitor.GetDockerHost(agentID)
if shouldHide {
if !hostExists {
writeErrorResponse(w, http.StatusNotFound, "docker_agent_not_found", "Docker / Podman module not found", nil)
return
}
host, err := monitor.HideDockerHost(agentID)
if err != nil {
writeErrorResponse(w, http.StatusNotFound, "docker_agent_not_found", err.Error(), nil)
return
}
h.broadcastState(r.Context())
if err := utils.WriteJSONResponse(w, map[string]any{
"success": true,
"agentId": host.ID,
"message": "Docker / Podman module hidden",
}); err != nil {
log.Error().Err(err).Msg("Failed to serialize docker host operation response")
}
return
}
if !hostExists {
if force {
if err := utils.WriteJSONResponse(w, map[string]any{
"success": true,
"agentId": agentID,
"message": "Docker / Podman module already removed",
}); err != nil {
log.Error().Err(err).Msg("Failed to serialize docker host operation response")
}
return
}
writeErrorResponse(w, http.StatusNotFound, "docker_agent_not_found", "Docker / Podman module not found", nil)
return
}
if !force && strings.EqualFold(priorHost.Status, "online") {
writeErrorResponse(w, http.StatusGone, "docker_runtime_stop_retired", "Remote Docker runtime stop is unavailable until it is routed through the canonical action lifecycle.", nil)
return
}
host, err := monitor.RemoveDockerHost(agentID)
if err != nil {
writeErrorResponse(w, http.StatusNotFound, "docker_agent_not_found", err.Error(), nil)
return
}
h.broadcastState(r.Context())
if err := utils.WriteJSONResponse(w, map[string]any{
"success": true,
"agentId": host.ID,
"message": "Docker / Podman module removed",
}); err != nil {
log.Error().Err(err).Msg("Failed to serialize docker host operation response")
}
}
// HandleAllowReenroll clears the removal block for a Docker / Podman host to permit future reports.
func (h *DockerAgentHandlers) HandleAllowReenroll(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only POST is allowed", nil)
return
}
agentID := dockerRuntimeAgentIDFromPath(r.URL.Path, "/allow-reenroll")
if agentID == "" {
writeErrorResponse(w, http.StatusBadRequest, "missing_agent_id", "Agent ID is required", nil)
return
}
monitor := h.requireMonitor(w, r)
if monitor == nil {
return
}
if err := monitor.AllowDockerHostReenroll(agentID); err != nil {
writeErrorResponse(w, http.StatusBadRequest, "docker_agent_reenroll_failed", err.Error(), nil)
return
}
// Broadcast updated state to ensure the frontend reflects the change
h.broadcastState(r.Context())
if err := utils.WriteJSONResponse(w, map[string]any{
"success": true,
"agentId": agentID,
}); err != nil {
log.Error().Err(err).Msg("Failed to serialize docker host allow reenroll response")
}
}
// handleHostLifecycleAction runs the shared PUT docker host lifecycle flow:
// resolve the agent ID from the suffixed route, apply the monitor action,
// broadcast state, and respond. action returns the canonical host ID.
func (h *DockerAgentHandlers) handleHostLifecycleAction(
w http.ResponseWriter,
r *http.Request,
pathSuffix string,
successMsg string,
logMsg string,
action func(monitor *monitoring.Monitor, agentID string) (string, error),
) {
if r.Method != http.MethodPut {
writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only PUT is allowed", nil)
return
}
agentID := dockerRuntimeAgentIDFromPath(r.URL.Path, pathSuffix)
if agentID == "" {
writeErrorResponse(w, http.StatusBadRequest, "missing_agent_id", "Agent ID is required", nil)
return
}
monitor := h.requireMonitor(w, r)
if monitor == nil {
return
}
hostID, err := action(monitor, agentID)
if err != nil {
writeErrorResponse(w, http.StatusNotFound, "docker_agent_not_found", err.Error(), nil)
return
}
h.broadcastState(r.Context())
if err := utils.WriteJSONResponse(w, map[string]any{
"success": true,
"agentId": hostID,
"message": successMsg,
}); err != nil {
log.Error().Err(err).Msg(logMsg)
}
}
// HandleUnhideHost unhides a previously hidden Docker / Podman host.
func (h *DockerAgentHandlers) HandleUnhideHost(w http.ResponseWriter, r *http.Request) {
h.handleHostLifecycleAction(w, r, "/unhide", "Docker / Podman module unhidden", "Failed to serialize docker host unhide response",
func(monitor *monitoring.Monitor, agentID string) (string, error) {
host, err := monitor.UnhideDockerHost(agentID)
if err != nil {
return "", err
}
return host.ID, nil
})
}
// HandleMarkPendingUninstall marks a Docker / Podman host as pending uninstall.
func (h *DockerAgentHandlers) HandleMarkPendingUninstall(w http.ResponseWriter, r *http.Request) {
h.handleHostLifecycleAction(w, r, "/pending-uninstall", "Docker / Podman module marked as pending uninstall", "Failed to serialize docker host pending uninstall response",
func(monitor *monitoring.Monitor, agentID string) (string, error) {
host, err := monitor.MarkDockerHostPendingUninstall(agentID)
if err != nil {
return "", err
}
return host.ID, nil
})
}
// HandleSetCustomDisplayName updates the custom display name for a Docker / Podman host.
func (h *DockerAgentHandlers) HandleSetCustomDisplayName(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only PUT is allowed", nil)
return
}
agentID := dockerRuntimeAgentIDFromPath(r.URL.Path, "/display-name")
if agentID == "" {
writeErrorResponse(w, http.StatusBadRequest, "missing_agent_id", "Agent ID is required", nil)
return
}
// Limit request body to 8KB to prevent memory exhaustion
r.Body = http.MaxBytesReader(w, r.Body, 8*1024)
defer r.Body.Close()
var req struct {
DisplayName string `json:"displayName"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErrorResponse(w, http.StatusBadRequest, "invalid_json", "Failed to decode request body", map[string]string{"error": err.Error()})
return
}
customName := strings.TrimSpace(req.DisplayName)
monitor := h.requireMonitor(w, r)
if monitor == nil {
return
}
host, err := monitor.SetDockerHostCustomDisplayName(agentID, customName)
if err != nil {
writeErrorResponse(w, http.StatusNotFound, "docker_agent_not_found", err.Error(), nil)
return
}
h.broadcastState(r.Context())
if err := utils.WriteJSONResponse(w, map[string]any{
"success": true,
"agentId": host.ID,
"message": "Docker / Podman module custom display name updated",
}); err != nil {
log.Error().Err(err).Msg("Failed to serialize docker host custom display name response")
}
}
// HandleContainerUpdate triggers a container update through the Docker / Podman module.
// POST /api/agents/docker/containers/{containerId}/update
func (h *DockerAgentHandlers) HandleContainerUpdate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only POST is allowed", nil)
return
}
// Limit request body to 8KB to prevent memory exhaustion
r.Body = http.MaxBytesReader(w, r.Body, 8*1024)
defer r.Body.Close()
var req struct {
AgentID string `json:"agentId"`
ContainerID string `json:"containerId"`
ContainerName string `json:"containerName"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErrorResponse(w, http.StatusBadRequest, "invalid_json", "Failed to decode request body", map[string]string{"error": err.Error()})
return
}
agentID := strings.TrimSpace(req.AgentID)
if agentID == "" {
writeErrorResponse(w, http.StatusBadRequest, "missing_agent_id", "Agent ID is required", nil)
return
}
if req.ContainerID == "" {
writeErrorResponse(w, http.StatusBadRequest, "missing_container_id", "Container ID is required", nil)
return
}
writeErrorResponse(w, http.StatusGone, "docker_update_retired", "This endpoint was retired: container updates now run as audited actions through /api/actions. Refresh the Pulse UI to use the new update flow.", nil)
}
// HandleUpdateAll triggers an update for all containers with updates available on a Docker / Podman host.
// POST /api/agents/docker/runtimes/{agentId}/update-all
func (h *DockerAgentHandlers) HandleUpdateAll(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only POST is allowed", nil)
return
}
agentID := dockerRuntimeAgentIDFromPath(r.URL.Path, "/update-all")
if agentID == "" {
writeErrorResponse(w, http.StatusBadRequest, "missing_agent_id", "Agent ID is required", nil)
return
}
writeErrorResponse(w, http.StatusGone, "docker_update_all_retired", "This endpoint was retired: container updates now run as audited actions through /api/actions, one action per container. Refresh the Pulse UI to use the new update flow.", nil)
}
// HandleCheckUpdates triggers an immediate update check for all containers on a Docker / Podman host.
// POST /api/agents/docker/runtimes/{agentId}/check-updates
func (h *DockerAgentHandlers) HandleCheckUpdates(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only POST is allowed", nil)
return
}
agentID := dockerRuntimeAgentIDFromPath(r.URL.Path, "/check-updates")
if agentID == "" {
writeErrorResponse(w, http.StatusBadRequest, "missing_agent_id", "Agent ID is required", nil)
return
}
// Queue the check updates command
monitor := h.requireMonitor(w, r)
if monitor == nil {
return
}
commandStatus, err := monitor.QueueDockerCheckUpdatesCommand(agentID)
if err != nil {
writeErrorResponse(w, http.StatusBadRequest, "check_updates_command_failed", err.Error(), nil)
return
}
h.broadcastState(r.Context())
if err := utils.WriteJSONResponse(w, map[string]any{
"success": true,
"commandId": commandStatus.ID,
"agentId": agentID,
"message": "Check for updates command queued",
"note": "The agent will clear its registry cache and check for updates on the next report cycle",
}); err != nil {
log.Error().Err(err).Msg("Failed to serialize check updates response")
}
}