Watch
1
0
Fork
You've already forked RedFlag
0
RedFlag/server/internal/services/reconciler.go
Fimeg 6a4be4d12f refactor: timeout + reconciler tickers onto bgRunner
Last two raw tickers; now they get shutdown, panic isolation,
and /health/tasks like everything else.
2026-06-11 20:48:04 -04:00

175 lines
5.6 KiB
Go

package services
import (
"context"
"database/sql"
"errors"
"log"
"sync"
"github.com/Fimeg/RedFlag/server/internal/database/queries"
"github.com/Fimeg/RedFlag/server/internal/models"
"github.com/Fimeg/RedFlag/server/internal/services/upstream"
)
// Reconciler automatically matches agent-reported packages to tracked_software
// entries using the cascade: Repology aliases -> container image patterns ->
// exact source_ref name matching.
//
// mu + running together form a "single-flight" run-guard: mu protects the
// running flag only. ReconcileAll acquires mu briefly to check/set running,
// then releases it for the slow per-item work, then re-acquires to clear the
// flag. This prevents overlapping ReconcileAll runs without holding the lock
// across HTTP calls, DB writes, or the repology rate-limit sleep.
//
// Periodic execution is managed by the taskrunner; register ReconcileAll with
// bgRunner.Every so the runner owns the ticker, shutdown, and panic isolation.
type Reconciler struct {
agentSWQueries *queries.AgentTrackedSoftwareQueries
upstreamQ *queries.UpstreamQueries
reconcileQ *queries.ReconciliationQueries
aliasCache *upstream.RepologyCache
mu sync.Mutex
running bool
}
func NewReconciler(
atsQ *queries.AgentTrackedSoftwareQueries,
uq *queries.UpstreamQueries,
rq *queries.ReconciliationQueries,
ac *upstream.RepologyCache,
) *Reconciler {
return &Reconciler{
agentSWQueries: atsQ,
upstreamQ: uq,
reconcileQ: rq,
aliasCache: ac,
}
}
// ReconcileAll runs the full reconciliation cascade across all tracked software.
// It is single-flight: if a run is already in progress the new call returns
// immediately without blocking. mu is held only for the brief flag check/set,
// not across the slow per-item work (Repology HTTP + DB + rate-limit sleep).
func (r *Reconciler) ReconcileAll(ctx context.Context) {
r.mu.Lock()
if r.running {
r.mu.Unlock()
log.Printf("[INFO] [reconciler] reconcile_all skipped: already running")
return
}
r.running = true
r.mu.Unlock()
defer func() {
r.mu.Lock()
r.running = false
r.mu.Unlock()
}()
rows, err := r.upstreamQ.List()
if err != nil {
log.Printf("[ERROR] [reconciler] list tracked_software failed: %v", err)
return
}
matched := 0
for _, sw := range rows {
n := r.reconcileOne(ctx, sw)
matched += n
}
if matched > 0 {
log.Printf("[INFO] [reconciler] reconcile_all matched=%d tracked=%d", matched, len(rows))
}
}
// ReconcileOne runs reconciliation for a single tracked_software entry.
// It does not participate in the ReconcileAll run-guard: on-demand single-item
// reconciliation is always allowed regardless of whether a background run is
// in progress, and does not block the background run.
func (r *Reconciler) ReconcileOne(ctx context.Context, sw models.TrackedSoftware) int {
return r.reconcileOne(ctx, sw)
}
func (r *Reconciler) reconcileOne(ctx context.Context, sw models.TrackedSoftware) int {
count := 0
// Method 1: Repology alias matching
if sw.RepologySlug != nil && *sw.RepologySlug != "" {
r.ensureAliasCache(ctx, *sw.RepologySlug)
aliases, err := r.aliasCache.GetAliases(*sw.RepologySlug)
if err != nil {
log.Printf("[WARN] [reconciler] get_aliases slug=%s err=%v", *sw.RepologySlug, err)
} else if len(aliases) > 0 {
matches, err := r.reconcileQ.MatchByRepology(*sw.RepologySlug, aliases)
if err != nil {
log.Printf("[WARN] [reconciler] match_repology slug=%s err=%v", *sw.RepologySlug, err)
} else {
for _, m := range matches {
if err := r.upsertReconciled(m, "repology"); err != nil {
log.Printf("[WARN] [reconciler] upsert_repology agent=%s pkg=%s err=%v", m.AgentID, m.PackageName, err)
} else {
count++
}
}
}
}
}
// Method 2: Container image pattern matching
if sw.ContainerImagePattern != nil && *sw.ContainerImagePattern != "" {
matches, err := r.reconcileQ.MatchByContainer(*sw.ContainerImagePattern)
if err != nil {
log.Printf("[WARN] [reconciler] match_container pattern=%s err=%v", *sw.ContainerImagePattern, err)
} else {
for _, m := range matches {
if err := r.upsertReconciled(m, "container"); err != nil {
log.Printf("[WARN] [reconciler] upsert_container agent=%s pkg=%s err=%v", m.AgentID, m.PackageName, err)
} else {
count++
}
}
}
}
// Method 3: Exact source_ref matching (for npm/pypi/gem)
if sw.Source == "npm" || sw.Source == "pypi" || sw.Source == "rubygems" {
matches, err := r.reconcileQ.MatchByExactName(sw.SourceRef)
if err != nil {
log.Printf("[WARN] [reconciler] match_exact ref=%s err=%v", sw.SourceRef, err)
} else {
for _, m := range matches {
if err := r.upsertReconciled(m, "exact_name"); err != nil {
log.Printf("[WARN] [reconciler] upsert_exact agent=%s pkg=%s err=%v", m.AgentID, m.PackageName, err)
} else {
count++
}
}
}
}
return count
}
func (r *Reconciler) ensureAliasCache(ctx context.Context, slug string) {
aliases, err := r.aliasCache.GetAliases(slug)
if err != nil || len(aliases) == 0 {
if err := r.aliasCache.Refresh(ctx, slug); err != nil {
log.Printf("[WARN] [reconciler] ensure_alias_cache slug=%s err=%v", slug, err)
}
}
}
func (r *Reconciler) upsertReconciled(m models.MatchResult, method string) error {
input := models.AgentTrackedSoftwareInput{
TrackedSoftwareID: m.TrackedID,
InstalledVersion: m.Version,
}
_, err := r.agentSWQueries.UpsertReconciled(m.AgentID, input, method, m.PackageName)
// ON CONFLICT WHERE match_method='manual' skips the UPDATE and RETURNING returns
// zero rows → sql.ErrNoRows. Treat as a no-op: the manual binding is intentional.
if errors.Is(err, sql.ErrNoRows) {
return nil
}
return err
}