Files
courtmanr@gmail.com 68e557e9f0 Fix Docker agent test JSON-marshal race by making the hook per-Agent
Same class of race fixed for newTimerFn in 8e5ef365d: tests swapped the
package-level jsonMarshalFn hook while async goroutines leaked from
earlier tests (sendCommandAck ack retries via runAsync) could still be
reading it, tripping the race detector. Replace the global with a
per-Agent jsonMarshalFn seam (nil defaults to json.Marshal), make the
decode payload helpers Agent methods so they use it, and inject the
failing marshaller into the tests that previously swapped the global.
Verified with go test ./internal/dockeragent/ -race -count=20.

Contract-Neutral: test seam refactor to fix data race, no public contract delta

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 17:16:06 +01:00

135 lines
4.1 KiB
Go

package dockeragent
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
agentsdocker "github.com/rcourtman/pulse-go-rewrite/pkg/agents/docker"
)
type updateAllCommandPayload struct {
ContainerIDs []string `json:"containerIds"`
}
func (a *Agent) decodeUpdateAllPayload(payload map[string]any) (updateAllCommandPayload, error) {
var commandPayload updateAllCommandPayload
body, err := a.jsonMarshal(payload)
if err != nil {
return commandPayload, fmt.Errorf("marshal update_all command payload: %w", err)
}
if err := json.Unmarshal(body, &commandPayload); err != nil {
return commandPayload, fmt.Errorf("decode update_all command payload: %w", err)
}
if len(commandPayload.ContainerIDs) == 0 {
return commandPayload, errors.New("missing containerIds in payload")
}
seen := make(map[string]struct{}, len(commandPayload.ContainerIDs))
normalized := make([]string, 0, len(commandPayload.ContainerIDs))
for _, raw := range commandPayload.ContainerIDs {
id := strings.TrimSpace(raw)
if id == "" {
continue
}
if _, exists := seen[id]; exists {
continue
}
seen[id] = struct{}{}
normalized = append(normalized, id)
}
if len(normalized) == 0 {
return commandPayload, errors.New("containerIds contained no valid container IDs")
}
commandPayload.ContainerIDs = normalized
return commandPayload, nil
}
// handleUpdateAllCommand handles the update_all command from Pulse.
// It updates each container sequentially to avoid overloading the runtime and registry.
func (a *Agent) handleUpdateAllCommand(ctx context.Context, target TargetConfig, command agentsdocker.Command) error {
commandPayload, err := a.decodeUpdateAllPayload(command.Payload)
if err != nil {
a.logger.Error().Err(err).Msg("Update-all command missing or invalid containerIds in payload")
if err := a.sendCommandAck(ctx, target, command.ID, agentsdocker.CommandStatusFailed, "Missing containerIds in payload"); err != nil {
a.logger.Error().
Err(err).
Str("commandID", command.ID).
Str("target", target.URL).
Msg("Failed to send failure acknowledgement")
}
return nil
}
containerIDs := commandPayload.ContainerIDs
total := len(containerIDs)
a.logger.Info().
Str("commandID", command.ID).
Int("containers", total).
Msg("Received update_all command from Pulse")
if err := a.sendCommandAck(ctx, target, command.ID, agentsdocker.CommandStatusAcknowledged, fmt.Sprintf("Starting batch update (%d containers)", total)); err != nil {
a.logger.Error().
Err(err).
Str("commandID", command.ID).
Str("target", target.URL).
Msg("Failed to send acknowledgement to Pulse")
return nil
}
failures := 0
for i, containerID := range containerIDs {
index := i + 1
progressFn := func(step string) {
msg := fmt.Sprintf("[%d/%d] %s", index, total, step)
if err := a.sendCommandAck(ctx, target, command.ID, agentsdocker.CommandStatusInProgress, msg); err != nil {
a.logger.Warn().
Err(err).
Str("commandID", command.ID).
Str("containerId", containerID).
Str("target", target.URL).
Msg("Failed to send batch progress update")
}
}
progressFn(fmt.Sprintf("Updating container %s...", containerID))
result := a.updateContainerWithProgress(ctx, containerID, func(step string) {
progressFn(fmt.Sprintf("%s: %s", containerID, step))
})
if !result.Success {
failures++
progressFn(fmt.Sprintf("%s: FAILED (%s)", containerID, strings.TrimSpace(result.Error)))
continue
}
progressFn(fmt.Sprintf("%s: completed (%s)", containerID, result.ContainerName))
}
status := agentsdocker.CommandStatusCompleted
message := fmt.Sprintf("Batch update completed (%d/%d containers updated)", total-failures, total)
if failures > 0 {
status = agentsdocker.CommandStatusFailed
message = fmt.Sprintf("Batch update finished with failures (%d/%d containers updated)", total-failures, total)
}
if err := a.sendCommandAck(ctx, target, command.ID, status, message); err != nil {
a.logger.Error().
Err(err).
Str("commandID", command.ID).
Str("target", target.URL).
Str("status", status).
Msg("Failed to send completion acknowledgement to Pulse")
}
return nil
}