mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-02 05:20:18 +00:00
When a Docker host successfully completes a stop command and confirms it has disabled itself, automatically clear the removal block to allow immediate re-enrollment. This fixes the UX issue where users who remove a Docker host cannot immediately reinstall it with a new token, as the host ID remains blocked for 24 hours. The block is still needed to prevent zombie reports from stale agents, but once the agent confirms it stopped successfully, there's no need to keep the block. Changes: - Clear removal block in HandleCommandAck after successful host removal - Allows remove → reinstall workflow without manual intervention - Block remains for forced removals or offline hosts (as intended)
382 lines
12 KiB
Go
382 lines
12 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"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 ingest from the external Docker agent.
|
|
type DockerAgentHandlers struct {
|
|
monitor *monitoring.Monitor
|
|
wsHub *websocket.Hub
|
|
}
|
|
|
|
type dockerCommandAckRequest struct {
|
|
HostID string `json:"hostId"`
|
|
Status string `json:"status"`
|
|
Message string `json:"message,omitempty"`
|
|
}
|
|
|
|
// NewDockerAgentHandlers constructs a new Docker agent handler group.
|
|
func NewDockerAgentHandlers(m *monitoring.Monitor, hub *websocket.Hub) *DockerAgentHandlers {
|
|
return &DockerAgentHandlers{monitor: m, wsHub: hub}
|
|
}
|
|
|
|
// SetMonitor updates the monitor reference for docker agent handlers.
|
|
func (h *DockerAgentHandlers) SetMonitor(m *monitoring.Monitor) {
|
|
h.monitor = m
|
|
}
|
|
|
|
// HandleReport accepts heartbeat payloads from the Docker agent.
|
|
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
|
|
}
|
|
|
|
defer r.Body.Close()
|
|
|
|
var report agentsdocker.Report
|
|
if err := json.NewDecoder(r.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.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 agent report processed")
|
|
|
|
// Broadcast the updated state for near-real-time UI updates
|
|
go h.wsHub.BroadcastState(h.monitor.GetState().ToFrontend())
|
|
|
|
response := map[string]any{
|
|
"success": true,
|
|
"hostId": host.ID,
|
|
"containers": len(host.Containers),
|
|
"lastSeen": host.LastSeen,
|
|
}
|
|
|
|
if payload, cmd := h.monitor.FetchDockerCommandForHost(host.ID); cmd != nil {
|
|
commandResponse := map[string]any{
|
|
"id": cmd.ID,
|
|
"type": cmd.Type,
|
|
}
|
|
if payload != nil && 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 agent 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
|
|
}
|
|
|
|
// 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 agents 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
|
|
}
|
|
|
|
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 := strings.ToLower(strings.TrimSpace(req.Status))
|
|
switch status {
|
|
case "", "ack", "acknowledged":
|
|
status = monitoring.DockerCommandStatusAcknowledged
|
|
case "success", "completed", "complete":
|
|
status = monitoring.DockerCommandStatusCompleted
|
|
case "fail", "failed", "error":
|
|
status = monitoring.DockerCommandStatusFailed
|
|
default:
|
|
writeErrorResponse(w, http.StatusBadRequest, "invalid_status", "Invalid command status", nil)
|
|
return
|
|
}
|
|
|
|
commandStatus, hostID, shouldRemove, err := h.monitor.AcknowledgeDockerHostCommand(commandID, req.HostID, status, req.Message)
|
|
if err != nil {
|
|
writeErrorResponse(w, http.StatusBadRequest, "docker_command_ack_failed", err.Error(), nil)
|
|
return
|
|
}
|
|
|
|
if shouldRemove {
|
|
if _, removeErr := h.monitor.RemoveDockerHost(hostID); removeErr != nil {
|
|
log.Error().Err(removeErr).Str("dockerHostID", hostID).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 := h.monitor.AllowDockerHostReenroll(hostID); reenrollErr != nil {
|
|
log.Warn().Err(reenrollErr).Str("dockerHostID", hostID).Msg("Failed to clear removal block after successful stop")
|
|
}
|
|
}
|
|
}
|
|
|
|
go h.wsHub.BroadcastState(h.monitor.GetState().ToFrontend())
|
|
|
|
if err := utils.WriteJSONResponse(w, map[string]any{
|
|
"success": true,
|
|
"hostId": hostID,
|
|
"command": commandStatus,
|
|
}); err != nil {
|
|
log.Error().Err(err).Msg("Failed to serialize docker command acknowledgement response")
|
|
}
|
|
}
|
|
|
|
// HandleDeleteHost removes or hides a docker 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
|
|
}
|
|
|
|
trimmedPath := strings.TrimPrefix(r.URL.Path, "/api/agents/docker/hosts/")
|
|
hostID := strings.TrimSpace(trimmedPath)
|
|
if hostID == "" {
|
|
writeErrorResponse(w, http.StatusBadRequest, "missing_host_id", "Docker host 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.monitor.GetDockerHost(hostID)
|
|
|
|
if shouldHide {
|
|
if !hostExists {
|
|
writeErrorResponse(w, http.StatusNotFound, "docker_host_not_found", "Docker host not found", nil)
|
|
return
|
|
}
|
|
host, err := h.monitor.HideDockerHost(hostID)
|
|
if err != nil {
|
|
writeErrorResponse(w, http.StatusNotFound, "docker_host_not_found", err.Error(), nil)
|
|
return
|
|
}
|
|
|
|
go h.wsHub.BroadcastState(h.monitor.GetState().ToFrontend())
|
|
|
|
if err := utils.WriteJSONResponse(w, map[string]any{
|
|
"success": true,
|
|
"hostId": host.ID,
|
|
"message": "Docker host 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,
|
|
"hostId": hostID,
|
|
"message": "Docker host already removed",
|
|
}); err != nil {
|
|
log.Error().Err(err).Msg("Failed to serialize docker host operation response")
|
|
}
|
|
return
|
|
}
|
|
|
|
writeErrorResponse(w, http.StatusNotFound, "docker_host_not_found", "Docker host not found", nil)
|
|
return
|
|
}
|
|
|
|
if !force && strings.EqualFold(priorHost.Status, "online") {
|
|
command, err := h.monitor.QueueDockerHostStop(hostID)
|
|
if err != nil {
|
|
writeErrorResponse(w, http.StatusBadRequest, "docker_command_failed", err.Error(), nil)
|
|
return
|
|
}
|
|
|
|
go h.wsHub.BroadcastState(h.monitor.GetState().ToFrontend())
|
|
|
|
if err := utils.WriteJSONResponse(w, map[string]any{
|
|
"success": true,
|
|
"hostId": hostID,
|
|
"command": command,
|
|
"message": "Stop command queued",
|
|
}); err != nil {
|
|
log.Error().Err(err).Msg("Failed to serialize docker host stop command response")
|
|
}
|
|
return
|
|
}
|
|
|
|
host, err := h.monitor.RemoveDockerHost(hostID)
|
|
if err != nil {
|
|
writeErrorResponse(w, http.StatusNotFound, "docker_host_not_found", err.Error(), nil)
|
|
return
|
|
}
|
|
|
|
go h.wsHub.BroadcastState(h.monitor.GetState().ToFrontend())
|
|
|
|
if err := utils.WriteJSONResponse(w, map[string]any{
|
|
"success": true,
|
|
"hostId": host.ID,
|
|
"message": "Docker host removed",
|
|
}); err != nil {
|
|
log.Error().Err(err).Msg("Failed to serialize docker host operation response")
|
|
}
|
|
}
|
|
|
|
// HandleAllowReenroll clears the removal block for a docker 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
|
|
}
|
|
|
|
trimmedPath := strings.TrimPrefix(r.URL.Path, "/api/agents/docker/hosts/")
|
|
trimmedPath = strings.TrimSuffix(trimmedPath, "/allow-reenroll")
|
|
hostID := strings.TrimSpace(trimmedPath)
|
|
if hostID == "" {
|
|
writeErrorResponse(w, http.StatusBadRequest, "missing_host_id", "Docker host ID is required", nil)
|
|
return
|
|
}
|
|
|
|
if err := h.monitor.AllowDockerHostReenroll(hostID); err != nil {
|
|
writeErrorResponse(w, http.StatusBadRequest, "docker_host_reenroll_failed", err.Error(), nil)
|
|
return
|
|
}
|
|
|
|
if err := utils.WriteJSONResponse(w, map[string]any{
|
|
"success": true,
|
|
"hostId": hostID,
|
|
}); err != nil {
|
|
log.Error().Err(err).Msg("Failed to serialize docker host allow reenroll response")
|
|
}
|
|
}
|
|
|
|
// HandleUnhideHost unhides a previously hidden docker host.
|
|
func (h *DockerAgentHandlers) HandleUnhideHost(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPut {
|
|
writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only PUT is allowed", nil)
|
|
return
|
|
}
|
|
|
|
trimmedPath := strings.TrimPrefix(r.URL.Path, "/api/agents/docker/hosts/")
|
|
trimmedPath = strings.TrimSuffix(trimmedPath, "/unhide")
|
|
hostID := strings.TrimSpace(trimmedPath)
|
|
if hostID == "" {
|
|
writeErrorResponse(w, http.StatusBadRequest, "missing_host_id", "Docker host ID is required", nil)
|
|
return
|
|
}
|
|
|
|
host, err := h.monitor.UnhideDockerHost(hostID)
|
|
if err != nil {
|
|
writeErrorResponse(w, http.StatusNotFound, "docker_host_not_found", err.Error(), nil)
|
|
return
|
|
}
|
|
|
|
go h.wsHub.BroadcastState(h.monitor.GetState().ToFrontend())
|
|
|
|
if err := utils.WriteJSONResponse(w, map[string]any{
|
|
"success": true,
|
|
"hostId": host.ID,
|
|
"message": "Docker host unhidden",
|
|
}); err != nil {
|
|
log.Error().Err(err).Msg("Failed to serialize docker host unhide response")
|
|
}
|
|
}
|
|
|
|
// HandleMarkPendingUninstall marks a docker host as pending uninstall.
|
|
func (h *DockerAgentHandlers) HandleMarkPendingUninstall(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPut {
|
|
writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only PUT is allowed", nil)
|
|
return
|
|
}
|
|
|
|
trimmedPath := strings.TrimPrefix(r.URL.Path, "/api/agents/docker/hosts/")
|
|
trimmedPath = strings.TrimSuffix(trimmedPath, "/pending-uninstall")
|
|
hostID := strings.TrimSpace(trimmedPath)
|
|
if hostID == "" {
|
|
writeErrorResponse(w, http.StatusBadRequest, "missing_host_id", "Docker host ID is required", nil)
|
|
return
|
|
}
|
|
|
|
host, err := h.monitor.MarkDockerHostPendingUninstall(hostID)
|
|
if err != nil {
|
|
writeErrorResponse(w, http.StatusNotFound, "docker_host_not_found", err.Error(), nil)
|
|
return
|
|
}
|
|
|
|
go h.wsHub.BroadcastState(h.monitor.GetState().ToFrontend())
|
|
|
|
if err := utils.WriteJSONResponse(w, map[string]any{
|
|
"success": true,
|
|
"hostId": host.ID,
|
|
"message": "Docker host marked as pending uninstall",
|
|
}); err != nil {
|
|
log.Error().Err(err).Msg("Failed to serialize docker host pending uninstall response")
|
|
}
|
|
}
|