mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-08-23 16:03:30 +00:00
v6.1.0-rc.1 retired the legacy update endpoints before a replacement existed, so the UI's Update button failed with an internal-jargon 410 (issue #1564). This lands the replacement end to end: update_container is a typed agentexec operation with its own strict codec, durable receipts, and a request digest bound to the image digest the plan observed; the unified agent bridges execution to the Docker module's existing pull/backup/recreate/verify/rollback implementation (which now reports rollback attempt and outcome); and the container action executor plans, dispatches, and reconciles the operation with declared backup/rollback compensation truth. Containers advertise an admin-approval update capability while an image update with a stated current digest is detected. The legacy endpoints stay retired but return actionable copy. Proven live against a Colima daemon: single-container update, the issue-1564 shared-network-namespace update, and the full UI journey (Update button, governed review, approve, run) all completed with the namespace preserved and the backup retained.
587 lines
21 KiB
Go
587 lines
21 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"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
|
|
}
|
|
|
|
// Limit request body to 2MB to prevent memory exhaustion
|
|
// (512KB was too small for users with 100+ containers)
|
|
r.Body = http.MaxBytesReader(w, r.Body, 2*1024*1024)
|
|
defer r.Body.Close()
|
|
|
|
// Support gzip-compressed reports from agents (backward compatible with uncompressed)
|
|
body, err := utils.DecompressBodyIfGzipped(r, 10*1024*1024)
|
|
if err != nil {
|
|
writeErrorResponse(w, http.StatusUnsupportedMediaType, "unsupported_encoding", err.Error(), nil)
|
|
return
|
|
}
|
|
defer body.Close()
|
|
|
|
var report agentsdocker.Report
|
|
if err := json.NewDecoder(body).Decode(&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)
|
|
host, err := h.getMonitor(r.Context()).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 := h.getMonitor(r.Context()).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")
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
mon := h.getMonitor(r.Context())
|
|
agentID := strings.TrimSpace(req.AgentID)
|
|
if agentID == "" {
|
|
writeErrorResponse(w, http.StatusBadRequest, "missing_agent_id", "Agent ID is required", nil)
|
|
return
|
|
}
|
|
|
|
commandStatus, agentID, shouldRemove, err := mon.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 := mon.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 := mon.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 := mon.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"
|
|
|
|
priorHost, hostExists := h.getMonitor(r.Context()).GetDockerHost(agentID)
|
|
|
|
if shouldHide {
|
|
if !hostExists {
|
|
writeErrorResponse(w, http.StatusNotFound, "docker_agent_not_found", "Docker / Podman module not found", nil)
|
|
return
|
|
}
|
|
host, err := h.getMonitor(r.Context()).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 := h.getMonitor(r.Context()).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
|
|
}
|
|
|
|
if err := h.getMonitor(r.Context()).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(ctx context.Context, 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
|
|
}
|
|
|
|
hostID, err := action(r.Context(), 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(ctx context.Context, agentID string) (string, error) {
|
|
host, err := h.getMonitor(ctx).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(ctx context.Context, agentID string) (string, error) {
|
|
host, err := h.getMonitor(ctx).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)
|
|
|
|
host, err := h.getMonitor(r.Context()).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
|
|
commandStatus, err := h.getMonitor(r.Context()).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")
|
|
}
|
|
}
|