mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-26 05:29:45 +00:00
94f96eb043
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.
659 lines
22 KiB
Go
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")
|
|
}
|
|
}
|