Watch
1
0
Fork
You've already forked RedFlag
0

upstream: keep drift detection-only

This commit is contained in:
Fimeg 2026-06-15 12:24:44 -04:00
commit e8cd4de44f
4 changed files with 10 additions and 291 deletions

View file

@ -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

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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.