Files
Pulse/internal/api/update_detection.go
rcourtman 3fdf753a5b Enhance devcontainer and CI workflows
- Add persistent volume mounts for Go/npm caches (faster rebuilds)
- Add shell config with helpful aliases and custom prompt
- Add comprehensive devcontainer documentation
- Add pre-commit hooks for Go formatting and linting
- Use go-version-file in CI workflows instead of hardcoded versions
- Simplify docker compose commands with --wait flag
- Add gitignore entries for devcontainer auth files

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 22:29:15 +00:00

324 lines
10 KiB
Go

package api
import (
"encoding/json"
"net/http"
"strings"
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
"github.com/rcourtman/pulse-go-rewrite/internal/utils"
"github.com/rs/zerolog/log"
)
// UpdateDetectionHandlers manages API endpoints for infrastructure update detection.
// This is separate from UpdateHandlers which handles Pulse self-updates.
type UpdateDetectionHandlers struct {
monitor *monitoring.Monitor
}
// NewUpdateDetectionHandlers creates a new update detection handlers group.
func NewUpdateDetectionHandlers(monitor *monitoring.Monitor) *UpdateDetectionHandlers {
return &UpdateDetectionHandlers{monitor: monitor}
}
// ContainerUpdateInfo represents a container with an available update
type ContainerUpdateInfo struct {
HostID string `json:"hostId"`
HostName string `json:"hostName"`
ContainerID string `json:"containerId"`
ContainerName string `json:"containerName"`
Image string `json:"image"`
CurrentDigest string `json:"currentDigest,omitempty"`
LatestDigest string `json:"latestDigest,omitempty"`
UpdateAvailable bool `json:"updateAvailable"`
LastChecked int64 `json:"lastChecked,omitempty"`
Error string `json:"error,omitempty"`
ResourceType string `json:"resourceType"`
}
// HandleGetInfraUpdates returns all tracked infrastructure updates with optional filtering.
// GET /api/infra-updates
//
// ?hostId=<id> Filter by host
// ?resourceType=docker Filter by type (currently only docker supported)
func (h *UpdateDetectionHandlers) HandleGetInfraUpdates(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only GET is allowed", nil)
return
}
if h.monitor == nil {
if err := utils.WriteJSONResponse(w, map[string]any{
"updates": []any{},
"total": 0,
}); err != nil {
log.Error().Err(err).Msg("Failed to serialize empty updates response")
}
return
}
// Parse query filters
query := r.URL.Query()
hostIDFilter := query.Get("hostId")
resourceTypeFilter := strings.ToLower(query.Get("resourceType"))
// Collect updates from Docker hosts
updates := h.collectDockerUpdates(hostIDFilter)
// Filter by resource type if specified
if resourceTypeFilter != "" && resourceTypeFilter != "docker" {
updates = []ContainerUpdateInfo{} // Only docker supported currently
}
response := map[string]any{
"updates": updates,
"total": len(updates),
}
if err := utils.WriteJSONResponse(w, response); err != nil {
log.Error().Err(err).Msg("Failed to serialize updates response")
}
}
// HandleGetInfraUpdateForResource returns the update status for a specific resource.
// GET /api/infra-updates/{resourceId}
func (h *UpdateDetectionHandlers) HandleGetInfraUpdateForResource(w http.ResponseWriter, r *http.Request, resourceID string) {
if r.Method != http.MethodGet {
writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only GET is allowed", nil)
return
}
if h.monitor == nil {
writeErrorResponse(w, http.StatusNotFound, "not_found", "No update found for resource", nil)
return
}
// ResourceID format: docker:<hostId>/<containerId>
updates := h.collectDockerUpdates("")
for _, update := range updates {
id := "docker:" + update.HostID + "/" + update.ContainerID
if id == resourceID || update.ContainerID == resourceID {
if err := utils.WriteJSONResponse(w, update); err != nil {
log.Error().Err(err).Msg("Failed to serialize update response")
}
return
}
}
writeErrorResponse(w, http.StatusNotFound, "not_found", "No update found for resource", nil)
}
// HandleGetInfraUpdatesSummary returns aggregated update statistics per host.
// GET /api/infra-updates/summary
func (h *UpdateDetectionHandlers) HandleGetInfraUpdatesSummary(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only GET is allowed", nil)
return
}
if h.monitor == nil {
if err := utils.WriteJSONResponse(w, map[string]any{
"summaries": map[string]any{},
"totalUpdates": 0,
}); err != nil {
log.Error().Err(err).Msg("Failed to serialize empty summary response")
}
return
}
updates := h.collectDockerUpdates("")
// Aggregate by host
summaries := make(map[string]map[string]any)
for _, update := range updates {
if _, ok := summaries[update.HostID]; !ok {
summaries[update.HostID] = map[string]any{
"hostId": update.HostID,
"hostName": update.HostName,
"totalCount": 0,
"containers": 0,
}
}
summaries[update.HostID]["totalCount"] = summaries[update.HostID]["totalCount"].(int) + 1
summaries[update.HostID]["containers"] = summaries[update.HostID]["containers"].(int) + 1
}
response := map[string]any{
"summaries": summaries,
"totalUpdates": len(updates),
}
if err := utils.WriteJSONResponse(w, response); err != nil {
log.Error().Err(err).Msg("Failed to serialize summary response")
}
}
// HandleTriggerInfraUpdateCheck triggers an update check for a specific resource or host.
// POST /api/infra-updates/check
//
// { "hostId": "xxx" } Check all on host
// { "resourceId": "xxx" } Check specific resource
func (h *UpdateDetectionHandlers) HandleTriggerInfraUpdateCheck(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only POST is allowed", nil)
return
}
if h.monitor == nil {
writeErrorResponse(w, http.StatusServiceUnavailable, "service_unavailable", "Update detection not available", nil)
return
}
// Limit request body
r.Body = http.MaxBytesReader(w, r.Body, 8*1024)
defer r.Body.Close()
var req struct {
HostID string `json:"hostId"`
ResourceID string `json:"resourceId"`
}
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
}
// Handle host-level check
if req.HostID != "" {
commandStatus, err := h.monitor.QueueDockerCheckUpdatesCommand(req.HostID)
if err != nil {
writeErrorResponse(w, http.StatusBadRequest, "check_updates_failed", err.Error(), nil)
return
}
if err := utils.WriteJSONResponse(w, map[string]any{
"success": true,
"commandId": commandStatus.ID,
"hostId": req.HostID,
"message": "Update check command queued for host",
}); err != nil {
log.Error().Err(err).Msg("Failed to serialize check response")
}
return
}
// Handle resource-level check (currently we just check the whole host)
if req.ResourceID != "" {
// Try to find which host this resource belongs to
updates := h.collectDockerUpdates("")
var hostID string
for _, update := range updates {
if update.ContainerID == req.ResourceID || ("docker:"+update.HostID+"/"+update.ContainerID) == req.ResourceID {
hostID = update.HostID
break
}
}
if hostID == "" {
writeErrorResponse(w, http.StatusNotFound, "not_found", "Resource not found or has no update status", nil)
return
}
commandStatus, err := h.monitor.QueueDockerCheckUpdatesCommand(hostID)
if err != nil {
writeErrorResponse(w, http.StatusBadRequest, "check_updates_failed", err.Error(), nil)
return
}
if err := utils.WriteJSONResponse(w, map[string]any{
"success": true,
"commandId": commandStatus.ID,
"hostId": hostID,
"message": "Update check command queued for host",
}); err != nil {
log.Error().Err(err).Msg("Failed to serialize check response")
}
return
}
writeErrorResponse(w, http.StatusBadRequest, "missing_params", "Either hostId or resourceId is required", nil)
}
// HandleGetInfraUpdatesForHost returns all updates for a specific host.
// GET /api/infra-updates/host/{hostId}
func (h *UpdateDetectionHandlers) HandleGetInfraUpdatesForHost(w http.ResponseWriter, r *http.Request, hostID string) {
if r.Method != http.MethodGet {
writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only GET is allowed", nil)
return
}
if h.monitor == nil {
if err := utils.WriteJSONResponse(w, map[string]any{
"updates": []any{},
"total": 0,
"hostId": hostID,
}); err != nil {
log.Error().Err(err).Msg("Failed to serialize empty host updates response")
}
return
}
updates := h.collectDockerUpdates(hostID)
response := map[string]any{
"updates": updates,
"total": len(updates),
"hostId": hostID,
}
if err := utils.WriteJSONResponse(w, response); err != nil {
log.Error().Err(err).Msg("Failed to serialize host updates response")
}
}
// collectDockerUpdates gathers update information from Docker hosts
func (h *UpdateDetectionHandlers) collectDockerUpdates(hostIDFilter string) []ContainerUpdateInfo {
var updates []ContainerUpdateInfo
state := h.monitor.GetState()
for _, host := range state.DockerHosts {
// Filter by host ID if specified
if hostIDFilter != "" && host.ID != hostIDFilter {
continue
}
for _, container := range host.Containers {
if container.UpdateStatus == nil {
continue
}
// Only include containers with updates available or errors
if !container.UpdateStatus.UpdateAvailable && container.UpdateStatus.Error == "" {
continue
}
update := ContainerUpdateInfo{
HostID: host.ID,
HostName: host.DisplayName,
ContainerID: container.ID,
ContainerName: strings.TrimPrefix(container.Name, "/"),
Image: container.Image,
UpdateAvailable: container.UpdateStatus.UpdateAvailable,
ResourceType: "docker",
}
if container.UpdateStatus.CurrentDigest != "" {
update.CurrentDigest = container.UpdateStatus.CurrentDigest
}
if container.UpdateStatus.LatestDigest != "" {
update.LatestDigest = container.UpdateStatus.LatestDigest
}
if !container.UpdateStatus.LastChecked.IsZero() {
update.LastChecked = container.UpdateStatus.LastChecked.Unix()
}
if container.UpdateStatus.Error != "" {
update.Error = container.UpdateStatus.Error
}
updates = append(updates, update)
}
}
return updates
}