Watch
1
0
Fork
You've already forked RedFlag
0
RedFlag/server/internal/orchestrator/timeouts.go
Fimeg 62f2764260 v0.2.5.1: lifecycle, live operations, unified history
Lifecycle:
- ReopenUpdate + ResolveUpdate replace RetryUpdate; routes for
  GET /updates/:id/lifecycle and POST reopen/resolve
- confirmUpdateCommand marks update_agent completed on version attestation
- migration 048: started/running added to update_logs.result
- helper atomic_replace_binary: copy-to-sibling then rename() (ETXTBSY)

Live operations:
- event_stream service + /events endpoints, LiveOperations page
- capability-token queries for the live view

History + CVE:
- History page reads /events/recent: filterable lifecycle/command/
  system/orchestrator timeline with agent crosslinks
- CVE drill-down: OSV parse carries CVSS vector, fixed version, published
  date, severity; issuer-linked aliases (CVE->NVD, GHSA->GitHub, ALSA->errata)
- DependencyClosureTree: one shared closure component in update detail
- STARTED (blue spinner) and PARTIAL (amber) result badges
2026-06-05 09:13:42 -04:00

127 lines
5.3 KiB
Go

package orchestrator
import (
"fmt"
"log"
"time"
"github.com/Fimeg/RedFlag/server/internal/models"
)
// dryRunRetryKey is the metadata counter the orchestrator uses to bound dry-run
// re-enqueue attempts on a stuck package, without holding state of its own.
const dryRunRetryKey = "orchestrator_dry_run_retries"
// sweepCheckingDependencies recovers packages stuck waiting on a dry-run report.
// A package that has sat in checking_dependencies past the threshold had its
// dry_run_update command lost or its agent-side dry-run fail silently. Re-enqueue
// the dry-run up to the retry budget; once spent, fail the package so it stops
// occupying an active state (ETHOS §3 — every active state has a timeout path).
func (o *Orchestrator) sweepCheckingDependencies(now time.Time) {
threshold := time.Duration(o.settings.GetOperationalInt("orchestrator_checking_timeout_minutes", 30)) * time.Minute
maxRetries := o.settings.GetOperationalInt("orchestrator_dry_run_max_retries", 2)
stuck, err := o.store.GetPackagesInStatus(models.StatusCheckingDependencies)
if err != nil {
log.Printf("[ERROR] [server] [orchestrator] sweep_checking_failed error=%v", err)
return
}
for _, pkg := range stuck {
if now.Sub(pkg.LastUpdatedAt) < threshold {
continue
}
attempts, err := o.store.BumpRetryCounter(pkg.ID, dryRunRetryKey)
if err != nil {
log.Printf("[ERROR] [server] [orchestrator] dry_run_retry_bump_failed update_id=%s error=%v", pkg.ID, err)
continue
}
if attempts > maxRetries {
meta := models.JSONB{
"failure_reason": "dry-run never reported; retry budget exhausted",
"failed_by": "orchestrator",
"dry_run_attempts": attempts - 1,
}
if err := o.store.TransitionByID(pkg.ID, models.StatusFailed, meta); err != nil {
log.Printf("[ERROR] [server] [orchestrator] checking_fail_transition_failed update_id=%s error=%v", pkg.ID, err)
continue
}
log.Printf("[INFO] [server] [orchestrator] checking_dependencies_failed update_id=%s package=%s/%s attempts=%d",
pkg.ID, pkg.PackageType, pkg.PackageName, attempts-1)
aid := pkg.AgentID
o.emitEvent(&aid, "orchestrator", "checking_dependencies_timeout", "error", "orchestrator",
"Dry-run never reported; retry budget exhausted", map[string]interface{}{
"update_id": pkg.ID.String(),
"package_type": pkg.PackageType,
"package_name": pkg.PackageName,
"dry_run_attempts": attempts - 1,
})
continue
}
p := pkg
if err := o.enqueuer.EnqueueDryRun(&p); err != nil {
log.Printf("[ERROR] [server] [orchestrator] dry_run_requeue_failed update_id=%s error=%v", pkg.ID, err)
continue
}
log.Printf("[INFO] [server] [orchestrator] dry_run_requeued update_id=%s package=%s/%s attempt=%d",
pkg.ID, pkg.PackageType, pkg.PackageName, attempts)
}
}
// sweepInstalling fails packages that entered installing but never reported a
// terminal result within the window (helper crashed mid-install, agent died, or
// the token was consumed but the result was lost). The 1h default matches the
// command-layer sent-timeout so the two layers don't fight over the same row.
func (o *Orchestrator) sweepInstalling(now time.Time) {
threshold := time.Duration(o.settings.GetOperationalInt("orchestrator_installing_timeout_minutes", 60)) * time.Minute
stuck, err := o.store.GetPackagesInStatus(models.StatusInstalling)
if err != nil {
log.Printf("[ERROR] [server] [orchestrator] sweep_installing_failed error=%v", err)
return
}
for _, pkg := range stuck {
if now.Sub(pkg.LastUpdatedAt) < threshold {
continue
}
meta := models.JSONB{
"failure_reason": fmt.Sprintf("install did not report a result within %s", threshold),
"failed_by": "orchestrator",
}
if err := o.store.TransitionByID(pkg.ID, models.StatusFailed, meta); err != nil {
log.Printf("[ERROR] [server] [orchestrator] installing_fail_transition_failed update_id=%s error=%v", pkg.ID, err)
continue
}
log.Printf("[INFO] [server] [orchestrator] installing_timed_out update_id=%s package=%s/%s threshold=%s",
pkg.ID, pkg.PackageType, pkg.PackageName, threshold)
aid := pkg.AgentID
o.emitEvent(&aid, "orchestrator", "installing_timeout", "error", "orchestrator",
"Install did not report a result within threshold",
map[string]interface{}{
"update_id": pkg.ID.String(),
"package_type": pkg.PackageType,
"package_name": pkg.PackageName,
"threshold": threshold.String(),
})
}
}
// sweepPendingDependencies surfaces packages an operator left unconfirmed for a
// long time. Per design this is NOT a state change — the operator deliberately
// deferred, and the package ages out on the next scan. We only log the count so
// the condition is visible; the operator-facing warning indicator is LIFECYCLE-004.
func (o *Orchestrator) sweepPendingDependencies(now time.Time) {
threshold := time.Duration(o.settings.GetOperationalInt("orchestrator_pending_deps_warn_hours", 24)) * time.Hour
stuck, err := o.store.GetPackagesInStatus(models.StatusPendingDependencies)
if err != nil {
log.Printf("[ERROR] [server] [orchestrator] sweep_pending_deps_failed error=%v", err)
return
}
stale := 0
for _, pkg := range stuck {
if now.Sub(pkg.LastUpdatedAt) >= threshold {
stale++
}
}
if stale > 0 {
log.Printf("[INFO] [server] [orchestrator] pending_dependencies_stale count=%d threshold=%s", stale, threshold)
}
}