upstream: keep drift detection-only
This commit is contained in:
parent
9e4d59695f
commit
e8cd4de44f
4 changed files with 10 additions and 291 deletions
|
|
@ -32,6 +32,10 @@ Format: version, date, then grouped by category (Added, Changed, Removed, Fixed,
|
|||
- Web: client errors log at the boundary (axios interceptor, ErrorBoundary, global
|
||||
handlers) instead of through a toast-coupled wrapper.
|
||||
|
||||
### Removed
|
||||
- Premature drift-to-update apply hooks: tracked-software drift remains an
|
||||
identification surface until the operator install path is scoped.
|
||||
|
||||
### Fixed
|
||||
- Web: 15 dashboard correctness/UX defects from the UI/UX audit — Docker filter cards that
|
||||
matched nothing, duplicated retry/cancel hooks, WebSocket reconnect leaks, notification
|
||||
|
|
|
|||
|
|
@ -502,7 +502,7 @@ func main() {
|
|||
// /health/tasks visibility (mirrors the old loop()'s initial ReconcileAll call).
|
||||
bgRunner.Go("sw_reconcile_initial", func() { reconciler.ReconcileAll(context.Background()) })
|
||||
bgRunner.Every("sw_reconcile", time.Hour, 5*time.Minute, func() { reconciler.ReconcileAll(context.Background()) })
|
||||
agentTrackedSoftwareHandler := handlers.NewAgentTrackedSoftwareHandler(agentTrackedSoftwareQueries, upstreamQueries, updateQueries)
|
||||
agentTrackedSoftwareHandler := handlers.NewAgentTrackedSoftwareHandler(agentTrackedSoftwareQueries, upstreamQueries)
|
||||
rateLimitHandler := handlers.NewRateLimitHandler(rateLimiter)
|
||||
// ISSUE-002: Pass signingService for install script signature verification
|
||||
downloadHandler := handlers.NewDownloadHandler(filepath.Join("/app"), cfg, packageQueries, signingService)
|
||||
|
|
@ -977,8 +977,6 @@ func main() {
|
|||
// boundary is the agent id (queries.Delete enforces the scope).
|
||||
admin.GET("/agents/:id/tracked-software", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), agentTrackedSoftwareHandler.ListByAgent)
|
||||
admin.POST("/agents/:id/tracked-software", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), agentTrackedSoftwareHandler.Upsert)
|
||||
admin.POST("/agents/:id/tracked-software/create-update", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), agentTrackedSoftwareHandler.CreateUpdateFromDrift)
|
||||
admin.GET("/agents/:id/tracked-software/:bindingID/install-script", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), agentTrackedSoftwareHandler.GenerateInstallScript)
|
||||
admin.DELETE("/agents/:id/tracked-software/:bindingID", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), agentTrackedSoftwareHandler.Delete)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,11 +3,8 @@ package handlers
|
|||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/server/internal/database/queries"
|
||||
"github.com/Fimeg/RedFlag/server/internal/models"
|
||||
|
|
@ -20,16 +17,14 @@ import (
|
|||
// becomes per-host actionable. All routes mount under the existing admin
|
||||
// group; auth is enforced by WebAuthMiddleware on the parent group.
|
||||
type AgentTrackedSoftwareHandler struct {
|
||||
bindings *queries.AgentTrackedSoftwareQueries
|
||||
upstream *queries.UpstreamQueries
|
||||
updateQueries *queries.UpdateQueries
|
||||
bindings *queries.AgentTrackedSoftwareQueries
|
||||
upstream *queries.UpstreamQueries
|
||||
}
|
||||
|
||||
func NewAgentTrackedSoftwareHandler(b *queries.AgentTrackedSoftwareQueries, u *queries.UpstreamQueries, updateQueries *queries.UpdateQueries) *AgentTrackedSoftwareHandler {
|
||||
func NewAgentTrackedSoftwareHandler(b *queries.AgentTrackedSoftwareQueries, u *queries.UpstreamQueries) *AgentTrackedSoftwareHandler {
|
||||
return &AgentTrackedSoftwareHandler{
|
||||
bindings: b,
|
||||
upstream: u,
|
||||
updateQueries: updateQueries,
|
||||
bindings: b,
|
||||
upstream: u,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -143,222 +138,3 @@ func (h *AgentTrackedSoftwareHandler) Delete(c *gin.Context) {
|
|||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// CreateUpdateFromDrift creates a pending UpdatePackage from a drifted bindingView.
|
||||
// POST /admin/agents/:id/tracked-software/create-update
|
||||
// B5: Install script is generated to download from Gitea and run.
|
||||
func (h *AgentTrackedSoftwareHandler) CreateUpdateFromDrift(c *gin.Context) {
|
||||
agentID, err := uuid.FromString(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid agent id"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get bindings for this agent and find which ones have drift
|
||||
bindings, events, err := h.bindings.ListDriftedBindings()
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] [server] [agent_tracked_software] list_drifted_bindings err=%v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list drifted bindings"})
|
||||
return
|
||||
}
|
||||
|
||||
// Filter to only this agent's bindings
|
||||
var agentBindings []models.AgentTrackedSoftwareView
|
||||
var agentEvents []models.UpstreamDriftEvent
|
||||
for i := range bindings {
|
||||
if bindings[i].AgentID == agentID {
|
||||
agentBindings = append(agentBindings, bindings[i])
|
||||
}
|
||||
}
|
||||
for i := range events {
|
||||
if events[i].TrackedSoftwareID != uuid.Nil {
|
||||
// Check if this software has a binding for this agent
|
||||
for _, b := range agentBindings {
|
||||
if b.TrackedSoftwareID == events[i].TrackedSoftwareID {
|
||||
agentEvents = append(agentEvents, events[i])
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Map of software_id -> latest drift event
|
||||
eventMap := make(map[uuid.UUID]*models.UpstreamDriftEvent)
|
||||
for _, e := range agentEvents {
|
||||
if eventMap[e.TrackedSoftwareID] == nil || e.ObservedAt.After(eventMap[e.TrackedSoftwareID].ObservedAt) {
|
||||
eventMap[e.TrackedSoftwareID] = &e
|
||||
}
|
||||
}
|
||||
|
||||
// Create pending UpdatePackage for each drifted binding
|
||||
for _, binding := range agentBindings {
|
||||
if binding.LatestVersion == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
event, ok := eventMap[binding.TrackedSoftwareID]
|
||||
if !ok {
|
||||
// No drift event found, skip
|
||||
continue
|
||||
}
|
||||
|
||||
// Build UpdateEvent and write to current_package_state
|
||||
updateEvent := models.UpdateEvent{
|
||||
ID: uuid.Must(uuid.NewV4()),
|
||||
AgentID: agentID,
|
||||
PackageType: binding.Ecosystem,
|
||||
PackageName: binding.Name,
|
||||
VersionFrom: binding.InstalledVersion,
|
||||
VersionTo: *binding.LatestVersion,
|
||||
Severity: event.DriftSeverity,
|
||||
RepositorySource: binding.Source + "://" + binding.SourceRef,
|
||||
EventType: "drift",
|
||||
CreatedAt: time.Now().UTC(),
|
||||
Metadata: models.JSONB{
|
||||
"source": binding.Source,
|
||||
"source_ref": binding.SourceRef,
|
||||
"installed": binding.InstalledVersion,
|
||||
"latest": *binding.LatestVersion,
|
||||
"drift_event": event.ID.String(),
|
||||
"install_type": "gitea_download",
|
||||
},
|
||||
}
|
||||
|
||||
if err := h.updateQueries.UpsertCurrentState(&updateEvent); err != nil {
|
||||
log.Printf("[ERROR] [server] [agent_tracked_software] create_update_from_drift agent=%s software=%s err=%v",
|
||||
agentID, binding.Name, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "failed to create update",
|
||||
"package": binding.Name,
|
||||
"update_err": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[INFO] [server] [agent_tracked_software] create_update_from_drift agent=%s package=%s %s -> %s",
|
||||
agentID, binding.Name, binding.InstalledVersion, *binding.LatestVersion)
|
||||
}
|
||||
|
||||
// Return summary
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": fmt.Sprintf("Created %d pending update(s) from drift", len(agentBindings)),
|
||||
"updates": agentBindings,
|
||||
})
|
||||
}
|
||||
|
||||
// GenerateInstallScript returns a shell script that downloads the release
|
||||
// from Gitea and runs it. B5: install via one-click download script.
|
||||
func (h *AgentTrackedSoftwareHandler) GenerateInstallScript(c *gin.Context) {
|
||||
agentID, err := uuid.FromString(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid agent id"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get binding
|
||||
bindingIDStr := c.Param("bindingID")
|
||||
var bindingID uuid.UUID
|
||||
if bindingIDStr != "" {
|
||||
bindingID, err = uuid.FromString(bindingIDStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid binding id"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Get binding as View to access Name field
|
||||
bindingViews, err := h.bindings.ListByAgent(agentID)
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] [server] [agent_tracked_software] list_by_agent agent=%s err=%v",
|
||||
agentID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list bindings"})
|
||||
return
|
||||
}
|
||||
|
||||
// Find the specific binding
|
||||
var bindingView *models.AgentTrackedSoftwareView
|
||||
for i := range bindingViews {
|
||||
if bindingViews[i].BindingID == bindingID {
|
||||
bindingView = &bindingViews[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if bindingView == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "binding not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get tracked software details
|
||||
// TODO: populate from the resolved binding/software record once wired:
|
||||
// bindingView.InstallPath, bindingView.Notes, bindingView.LastObservedAt
|
||||
software, err := h.upstream.GetByID(bindingView.TrackedSoftwareID)
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] [server] [agent_tracked_software] get_software software=%s err=%v",
|
||||
bindingView.TrackedSoftwareID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to look up tracked software"})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate install script
|
||||
script := fmt.Sprintf(`#!/bin/bash
|
||||
# Auto-generated install script for %s
|
||||
# Source: %s/%s
|
||||
# Current: %s -> Target: %s
|
||||
|
||||
set -e
|
||||
|
||||
echo "Installing %s %s -> %s"
|
||||
|
||||
# Download the release from Gitea
|
||||
GITEA_HOST=%s
|
||||
GITEA_TOKEN=%s
|
||||
|
||||
# Determine the release asset name
|
||||
# This assumes the release has an asset matching the platform
|
||||
PLATFORM=%s
|
||||
VERSION=%s
|
||||
|
||||
# Download the release tarball
|
||||
RELEASE_URL="https://%s/api/v1/repos/%s/releases/tags/v%s"
|
||||
echo "Downloading from: $RELEASE_URL"
|
||||
|
||||
tmp_dir=$(mktemp -d)
|
||||
trap "rm -rf $tmp_dir" EXIT
|
||||
|
||||
curl -sSL -o "$tmp_dir/release.tar.gz" "$RELEASE_URL"
|
||||
tar -xzf "$tmp_dir/release.tar.gz" -C "$tmp_dir"
|
||||
|
||||
# Extract binary from release (adjust path based on actual release structure)
|
||||
# For now, assume it's a single binary in the tarball
|
||||
if [ -f "$tmp_dir/$(basename %s)" ]; then
|
||||
cp "$tmp_dir/$(basename %s)" %s
|
||||
chmod +x %s
|
||||
echo "Installed %s successfully"
|
||||
else
|
||||
echo "WARNING: Could not find binary $(basename %s) in release"
|
||||
echo "Check the release assets manually at: https://%s/repos/%s/releases"
|
||||
fi
|
||||
`,
|
||||
bindingView.Name,
|
||||
software.Source, software.SourceRef,
|
||||
bindingView.InstalledVersion, *software.LatestVersion,
|
||||
bindingView.Name, bindingView.InstalledVersion, *software.LatestVersion,
|
||||
strings.Split(software.Source, "://")[1], // Extract host from source URL
|
||||
c.GetHeader("X-User-Token"), // Will be set by auth middleware
|
||||
software.SourceRef,
|
||||
bindingView.InstalledVersion,
|
||||
software.Source,
|
||||
strings.Split(software.Source, "://")[1],
|
||||
software.SourceRef,
|
||||
*software.LatestVersion,
|
||||
bindingView.Name,
|
||||
bindingView.Name, bindingView.Name, bindingView.Name,
|
||||
bindingView.Name,
|
||||
strings.Split(software.Source, "://")[1], software.SourceRef,
|
||||
)
|
||||
|
||||
c.Header("Content-Type", "text/plain; charset=utf-8")
|
||||
c.Header("Content-Disposition", "attachment; filename=install_"+bindingView.Name+".sh")
|
||||
c.String(http.StatusOK, script)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -165,65 +165,6 @@ func (q *AgentTrackedSoftwareQueries) RecomputeCurrentVersion(softwareID uuid.UU
|
|||
return nil
|
||||
}
|
||||
|
||||
// ListDriftedBindings returns bindings where the associated tracked_software
|
||||
// has drifted (latest_version != installed_version). Joins with the most
|
||||
// recent upstream_drift_event to include drift metadata for the UI.
|
||||
//
|
||||
// This is the A3 query — drift events + bindings = pending updates.
|
||||
func (q *AgentTrackedSoftwareQueries) ListDriftedBindings() ([]models.AgentTrackedSoftwareView, []models.UpstreamDriftEvent, error) {
|
||||
// Define Row struct to match the named SQL query columns
|
||||
type Row struct {
|
||||
Software models.AgentTrackedSoftwareView
|
||||
Drifted bool
|
||||
PastEOL bool
|
||||
Event models.UpstreamDriftEvent
|
||||
}
|
||||
var rows []Row
|
||||
|
||||
err := q.db.Select(&rows, `
|
||||
SELECT
|
||||
b.binding_id, b.agent_id, b.tracked_software_id,
|
||||
t.name, t.ecosystem, t.source, t.source_ref,
|
||||
b.installed_version, t.latest_version, t.latest_at, t.eol_at,
|
||||
b.install_path, b.notes, b.last_observed_at,
|
||||
t.last_synced_at, t.last_error,
|
||||
(t.latest_version IS NOT NULL AND b.installed_version <> t.latest_version) AS drifted,
|
||||
(t.eol_at IS NOT NULL AND t.eol_at < NOW()) AS past_eol,
|
||||
d.id AS drift_id, d.observed_at, d.drift_severity, d.from_version, d.to_version, d.note
|
||||
FROM agent_tracked_software b
|
||||
JOIN tracked_software t ON t.id = b.tracked_software_id
|
||||
JOIN upstream_drift_events d ON d.tracked_software_id = t.id
|
||||
WHERE t.latest_version IS NOT NULL
|
||||
AND b.installed_version <> t.latest_version
|
||||
ORDER BY d.observed_at DESC`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("agent_tracked_software: list_drifted_bindings: %w", err)
|
||||
}
|
||||
|
||||
var bindings []models.AgentTrackedSoftwareView
|
||||
var events []models.UpstreamDriftEvent
|
||||
|
||||
for _, r := range rows {
|
||||
binding := r.Software
|
||||
binding.Drifted = r.Drifted
|
||||
binding.PastEOL = r.PastEOL
|
||||
|
||||
events = append(events, models.UpstreamDriftEvent{
|
||||
ID: r.Event.ID,
|
||||
TrackedSoftwareID: r.Event.TrackedSoftwareID,
|
||||
ObservedAt: r.Event.ObservedAt,
|
||||
DriftSeverity: r.Event.DriftSeverity,
|
||||
FromVersion: r.Event.FromVersion,
|
||||
ToVersion: r.Event.ToVersion,
|
||||
Note: r.Event.Note,
|
||||
})
|
||||
bindings = append(bindings, binding)
|
||||
}
|
||||
|
||||
return bindings, events, nil
|
||||
}
|
||||
|
||||
// UpsertReconciled creates or updates a binding with reconciliation metadata.
|
||||
// Preserves manual bindings (match_method='manual') — automated reconciliation
|
||||
// never overwrites operator-created bindings.
|
||||
|
|
|
|||
Loading…
Reference in a new issue