Files
Redflag/aggregator-server/internal/api/handlers/updates.go
Fimeg 4ef5216c89 Windows agent improvements and dependency workflow fixes
- Added system info reporting to agent main loop
- Updated README with current project status and screenshots
- Fixed a few workflow quirks
2025-10-17 16:26:21 -04:00

691 lines
21 KiB
Go

package handlers
import (
"fmt"
"net/http"
"strconv"
"time"
"github.com/aggregator-project/aggregator-server/internal/database/queries"
"github.com/aggregator-project/aggregator-server/internal/models"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type UpdateHandler struct {
updateQueries *queries.UpdateQueries
agentQueries *queries.AgentQueries
commandQueries *queries.CommandQueries
}
func NewUpdateHandler(uq *queries.UpdateQueries, aq *queries.AgentQueries, cq *queries.CommandQueries) *UpdateHandler {
return &UpdateHandler{
updateQueries: uq,
agentQueries: aq,
commandQueries: cq,
}
}
// ReportUpdates handles update reports from agents using event sourcing
func (h *UpdateHandler) ReportUpdates(c *gin.Context) {
agentID := c.MustGet("agent_id").(uuid.UUID)
// Update last_seen timestamp
if err := h.agentQueries.UpdateAgentLastSeen(agentID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update last seen"})
return
}
var req models.UpdateReportRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Convert update report items to events
events := make([]models.UpdateEvent, 0, len(req.Updates))
for _, item := range req.Updates {
event := models.UpdateEvent{
ID: uuid.New(),
AgentID: agentID,
PackageType: item.PackageType,
PackageName: item.PackageName,
VersionFrom: item.CurrentVersion,
VersionTo: item.AvailableVersion,
Severity: item.Severity,
RepositorySource: item.RepositorySource,
Metadata: item.Metadata,
EventType: "discovered",
CreatedAt: req.Timestamp,
}
events = append(events, event)
}
// Store events in batch with error isolation
if err := h.updateQueries.CreateUpdateEventsBatch(events); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to record update events"})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "update events recorded",
"count": len(events),
"command_id": req.CommandID,
})
}
// ListUpdates retrieves updates with filtering using the new state table
func (h *UpdateHandler) ListUpdates(c *gin.Context) {
filters := &models.UpdateFilters{
Status: c.Query("status"),
Severity: c.Query("severity"),
PackageType: c.Query("package_type"),
}
// Parse agent_id if provided
if agentIDStr := c.Query("agent_id"); agentIDStr != "" {
agentID, err := uuid.Parse(agentIDStr)
if err == nil {
filters.AgentID = agentID
}
}
// Parse pagination
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
filters.Page = page
filters.PageSize = pageSize
updates, total, err := h.updateQueries.ListUpdatesFromState(filters)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list updates"})
return
}
// Get overall statistics for the summary cards
stats, err := h.updateQueries.GetAllUpdateStats()
if err != nil {
// Don't fail the request if stats fail, just log and continue
// In production, we'd use proper logging
stats = &models.UpdateStats{}
}
c.JSON(http.StatusOK, gin.H{
"updates": updates,
"total": total,
"page": page,
"page_size": pageSize,
"stats": stats,
})
}
// GetUpdate retrieves a single update by ID
func (h *UpdateHandler) GetUpdate(c *gin.Context) {
idStr := c.Param("id")
id, err := uuid.Parse(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid update ID"})
return
}
update, err := h.updateQueries.GetUpdateByID(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "update not found"})
return
}
c.JSON(http.StatusOK, update)
}
// ApproveUpdate marks an update as approved
func (h *UpdateHandler) ApproveUpdate(c *gin.Context) {
idStr := c.Param("id")
id, err := uuid.Parse(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid update ID"})
return
}
// For now, use "admin" as approver. Will integrate with proper auth later
if err := h.updateQueries.ApproveUpdate(id, "admin"); err != nil {
fmt.Printf("DEBUG: ApproveUpdate failed for ID %s: %v\n", id, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to approve update: %v", err)})
return
}
c.JSON(http.StatusOK, gin.H{"message": "update approved"})
}
// ReportLog handles update execution logs from agents
func (h *UpdateHandler) ReportLog(c *gin.Context) {
agentID := c.MustGet("agent_id").(uuid.UUID)
// Update last_seen timestamp
if err := h.agentQueries.UpdateAgentLastSeen(agentID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update last seen"})
return
}
var req models.UpdateLogRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
log := &models.UpdateLog{
ID: uuid.New(),
AgentID: agentID,
Action: req.Action,
Result: req.Result,
Stdout: req.Stdout,
Stderr: req.Stderr,
ExitCode: req.ExitCode,
DurationSeconds: req.DurationSeconds,
ExecutedAt: time.Now(),
}
// Store the log entry
if err := h.updateQueries.CreateUpdateLog(log); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save log"})
return
}
// NEW: Update command status if command_id is provided
if req.CommandID != "" {
commandID, err := uuid.Parse(req.CommandID)
if err != nil {
// Log warning but don't fail the request
fmt.Printf("Warning: Invalid command ID format in log request: %s\n", req.CommandID)
} else {
// Prepare result data for command update
result := models.JSONB{
"stdout": req.Stdout,
"stderr": req.Stderr,
"exit_code": req.ExitCode,
"duration_seconds": req.DurationSeconds,
"logged_at": time.Now(),
}
// Update command status based on log result
if req.Result == "success" {
if err := h.commandQueries.MarkCommandCompleted(commandID, result); err != nil {
fmt.Printf("Warning: Failed to mark command %s as completed: %v\n", commandID, err)
}
} else if req.Result == "failed" || req.Result == "dry_run_failed" {
if err := h.commandQueries.MarkCommandFailed(commandID, result); err != nil {
fmt.Printf("Warning: Failed to mark command %s as failed: %v\n", commandID, err)
}
} else {
// For other results, just update the result field
if err := h.commandQueries.UpdateCommandResult(commandID, result); err != nil {
fmt.Printf("Warning: Failed to update command %s result: %v\n", commandID, err)
}
}
}
}
c.JSON(http.StatusOK, gin.H{"message": "log recorded"})
}
// GetPackageHistory returns version history for a specific package
func (h *UpdateHandler) GetPackageHistory(c *gin.Context) {
agentIDStr := c.Param("agent_id")
agentID, err := uuid.Parse(agentIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid agent ID"})
return
}
packageType := c.Query("package_type")
packageName := c.Query("package_name")
if packageType == "" || packageName == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "package_type and package_name are required"})
return
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
history, err := h.updateQueries.GetPackageHistory(agentID, packageType, packageName, limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get package history"})
return
}
c.JSON(http.StatusOK, gin.H{
"history": history,
"package_type": packageType,
"package_name": packageName,
"count": len(history),
})
}
// GetBatchStatus returns recent batch processing status for an agent
func (h *UpdateHandler) GetBatchStatus(c *gin.Context) {
agentIDStr := c.Param("agent_id")
agentID, err := uuid.Parse(agentIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid agent ID"})
return
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
batches, err := h.updateQueries.GetBatchStatus(agentID, limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get batch status"})
return
}
c.JSON(http.StatusOK, gin.H{
"batches": batches,
"count": len(batches),
})
}
// UpdatePackageStatus updates the status of a package (for when updates are installed)
func (h *UpdateHandler) UpdatePackageStatus(c *gin.Context) {
agentIDStr := c.Param("agent_id")
agentID, err := uuid.Parse(agentIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid agent ID"})
return
}
var req struct {
PackageType string `json:"package_type" binding:"required"`
PackageName string `json:"package_name" binding:"required"`
Status string `json:"status" binding:"required"`
Metadata map[string]interface{} `json:"metadata"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.updateQueries.UpdatePackageStatus(agentID, req.PackageType, req.PackageName, req.Status, req.Metadata); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update package status"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "package status updated"})
}
// ApproveUpdates handles bulk approval of updates
func (h *UpdateHandler) ApproveUpdates(c *gin.Context) {
var req struct {
UpdateIDs []string `json:"update_ids" binding:"required"`
ScheduledAt *string `json:"scheduled_at"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Convert string IDs to UUIDs
updateIDs := make([]uuid.UUID, 0, len(req.UpdateIDs))
for _, idStr := range req.UpdateIDs {
id, err := uuid.Parse(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid update ID: " + idStr})
return
}
updateIDs = append(updateIDs, id)
}
// For now, use "admin" as approver. Will integrate with proper auth later
if err := h.updateQueries.BulkApproveUpdates(updateIDs, "admin"); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to approve updates"})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "updates approved",
"count": len(updateIDs),
})
}
// RejectUpdate rejects a single update
func (h *UpdateHandler) RejectUpdate(c *gin.Context) {
idStr := c.Param("id")
id, err := uuid.Parse(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid update ID"})
return
}
// For now, use "admin" as rejecter. Will integrate with proper auth later
if err := h.updateQueries.RejectUpdate(id, "admin"); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reject update"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "update rejected"})
}
// InstallUpdate marks an update as ready for installation and creates a dry run command for the agent
func (h *UpdateHandler) InstallUpdate(c *gin.Context) {
idStr := c.Param("id")
id, err := uuid.Parse(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid update ID"})
return
}
// Get the full update details to extract agent_id, package_name, and package_type
update, err := h.updateQueries.GetUpdateByID(id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get update details"})
return
}
// Create a command for the agent to perform dry run first
command := &models.AgentCommand{
ID: uuid.New(),
AgentID: update.AgentID,
CommandType: models.CommandTypeDryRunUpdate,
Params: map[string]interface{}{
"update_id": id.String(),
"package_name": update.PackageName,
"package_type": update.PackageType,
},
Status: models.CommandStatusPending,
CreatedAt: time.Now(),
}
// Store the command in database
if err := h.commandQueries.CreateCommand(command); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create dry run command"})
return
}
// Update the package status to 'checking_dependencies' to show dry run is starting
if err := h.updateQueries.SetCheckingDependencies(id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update package status"})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "dry run command created for agent",
"command_id": command.ID.String(),
})
}
// GetUpdateLogs retrieves installation logs for a specific update
func (h *UpdateHandler) GetUpdateLogs(c *gin.Context) {
idStr := c.Param("id")
id, err := uuid.Parse(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid update ID"})
return
}
// Parse limit from query params
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
logs, err := h.updateQueries.GetUpdateLogs(id, limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to retrieve update logs"})
return
}
c.JSON(http.StatusOK, gin.H{
"logs": logs,
"count": len(logs),
})
}
// ReportDependencies handles dependency reporting from agents after dry run
func (h *UpdateHandler) ReportDependencies(c *gin.Context) {
agentID := c.MustGet("agent_id").(uuid.UUID)
// Update last_seen timestamp
if err := h.agentQueries.UpdateAgentLastSeen(agentID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update last seen"})
return
}
var req models.DependencyReportRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// If there are NO dependencies, auto-approve and proceed directly to installation
// This prevents updates with zero dependencies from getting stuck in "pending_dependencies"
if len(req.Dependencies) == 0 {
// Get the update by package to retrieve its ID
update, err := h.updateQueries.GetUpdateByPackage(agentID, req.PackageType, req.PackageName)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get update details"})
return
}
// Record that dependencies were checked (empty array) in metadata
if err := h.updateQueries.SetPendingDependencies(agentID, req.PackageType, req.PackageName, req.Dependencies); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update package metadata"})
return
}
// Automatically create installation command since no dependencies need approval
command := &models.AgentCommand{
ID: uuid.New(),
AgentID: agentID,
CommandType: models.CommandTypeConfirmDependencies,
Params: map[string]interface{}{
"update_id": update.ID.String(),
"package_name": req.PackageName,
"package_type": req.PackageType,
"dependencies": []string{}, // Empty dependencies array
},
Status: models.CommandStatusPending,
CreatedAt: time.Now(),
}
if err := h.commandQueries.CreateCommand(command); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create installation command"})
return
}
// Update status to installing since no approval needed
if err := h.updateQueries.InstallUpdate(update.ID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update package status to installing"})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "no dependencies found - installation command created automatically",
"command_id": command.ID.String(),
})
return
}
// If dependencies EXIST, require manual approval by setting status to pending_dependencies
if err := h.updateQueries.SetPendingDependencies(agentID, req.PackageType, req.PackageName, req.Dependencies); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update package status"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "dependencies reported and status updated"})
}
// ConfirmDependencies handles user confirmation to proceed with dependency installation
func (h *UpdateHandler) ConfirmDependencies(c *gin.Context) {
idStr := c.Param("id")
id, err := uuid.Parse(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid update ID"})
return
}
// Get the update details
update, err := h.updateQueries.GetUpdateByID(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "update not found"})
return
}
// Create a command for the agent to install with dependencies
command := &models.AgentCommand{
ID: uuid.New(),
AgentID: update.AgentID,
CommandType: models.CommandTypeConfirmDependencies,
Params: map[string]interface{}{
"update_id": id.String(),
"package_name": update.PackageName,
"package_type": update.PackageType,
"dependencies": update.Metadata["dependencies"], // Dependencies stored in metadata
},
Status: models.CommandStatusPending,
CreatedAt: time.Now(),
}
// Store the command in database
if err := h.commandQueries.CreateCommand(command); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create confirmation command"})
return
}
// Update the package status to 'installing'
if err := h.updateQueries.InstallUpdate(id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update package status"})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "dependency installation confirmed and command created",
"command_id": command.ID.String(),
})
}
// GetAllLogs retrieves logs across all agents with filtering for universal log view
func (h *UpdateHandler) GetAllLogs(c *gin.Context) {
filters := &models.LogFilters{
Action: c.Query("action"),
Result: c.Query("result"),
}
// Parse agent_id if provided
if agentIDStr := c.Query("agent_id"); agentIDStr != "" {
agentID, err := uuid.Parse(agentIDStr)
if err == nil {
filters.AgentID = agentID
}
}
// Parse since timestamp if provided
if sinceStr := c.Query("since"); sinceStr != "" {
sinceTime, err := time.Parse(time.RFC3339, sinceStr)
if err == nil {
filters.Since = &sinceTime
}
}
// Parse pagination
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "100"))
filters.Page = page
filters.PageSize = pageSize
logs, total, err := h.updateQueries.GetAllLogs(filters)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to retrieve logs"})
return
}
c.JSON(http.StatusOK, gin.H{
"logs": logs,
"total": total,
"page": page,
"page_size": pageSize,
})
}
// GetActiveOperations retrieves currently running operations for live status view
func (h *UpdateHandler) GetActiveOperations(c *gin.Context) {
operations, err := h.updateQueries.GetActiveOperations()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to retrieve active operations"})
return
}
c.JSON(http.StatusOK, gin.H{
"operations": operations,
"count": len(operations),
})
}
// RetryCommand retries a failed, timed_out, or cancelled command
func (h *UpdateHandler) RetryCommand(c *gin.Context) {
idStr := c.Param("id")
id, err := uuid.Parse(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid command ID"})
return
}
// Create a new command based on the original
newCommand, err := h.commandQueries.RetryCommand(id)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("failed to retry command: %v", err)})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "command retry created",
"command_id": newCommand.ID.String(),
"new_id": newCommand.ID.String(),
})
}
// CancelCommand cancels a pending or sent command
func (h *UpdateHandler) CancelCommand(c *gin.Context) {
idStr := c.Param("id")
id, err := uuid.Parse(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid command ID"})
return
}
// Cancel the command
if err := h.commandQueries.CancelCommand(id); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("failed to cancel command: %v", err)})
return
}
c.JSON(http.StatusOK, gin.H{"message": "command cancelled"})
}
// GetActiveCommands retrieves currently active commands for live operations view
func (h *UpdateHandler) GetActiveCommands(c *gin.Context) {
commands, err := h.commandQueries.GetActiveCommands()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to retrieve active commands"})
return
}
c.JSON(http.StatusOK, gin.H{
"commands": commands,
"count": len(commands),
})
}
// GetRecentCommands retrieves recent commands for retry functionality
func (h *UpdateHandler) GetRecentCommands(c *gin.Context) {
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
commands, err := h.commandQueries.GetRecentCommands(limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to retrieve recent commands"})
return
}
c.JSON(http.StatusOK, gin.H{
"commands": commands,
"count": len(commands),
"limit": limit,
})
}