|
| 1 | +package scanners |
| 2 | + |
| 3 | +import ( |
| 4 | + "context" |
| 5 | + "log/slog" |
| 6 | + "time" |
| 7 | + |
| 8 | + batchv1 "k8s.io/api/batch/v1" |
| 9 | + corev1 "k8s.io/api/core/v1" |
| 10 | + apierrors "k8s.io/apimachinery/pkg/api/errors" |
| 11 | + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" |
| 12 | +) |
| 13 | + |
| 14 | +// Job cleanup is normally handled by Kubernetes' TTLAfterFinished controller |
| 15 | +// (we stamp every Job with TTLSecondsAfterFinished=jobTTLSeconds). That |
| 16 | +// controller is GA and on by default since k8s 1.21, but a customer cluster |
| 17 | +// can have it disabled (kube-controller-manager --controllers=-ttl-after-finished, |
| 18 | +// hardened/managed distros, or pre-1.21). When it's off, finished scanner Jobs |
| 19 | +// and their Completed pods accumulate forever. |
| 20 | +// |
| 21 | +// The reaper makes cleanup independent of that controller: the agent itself |
| 22 | +// periodically deletes its own finished Jobs once they're past the same grace |
| 23 | +// window the TTL field encodes. It only ever touches Jobs carrying BOTH the |
| 24 | +// managed-by and orchestrator labels, scoped to the runner's namespace, so it |
| 25 | +// can never delete a customer's Jobs. Where the TTL controller IS present this |
| 26 | +// is a harmless no-op (the controller usually wins the race first). |
| 27 | +const ( |
| 28 | + // reaperInterval is how often the sweep runs. |
| 29 | + reaperInterval = 60 * time.Second |
| 30 | + // reaperSweepTimeout bounds a single sweep's API calls, safely below |
| 31 | + // reaperInterval so a hung API server can't wedge the loop. |
| 32 | + reaperSweepTimeout = 45 * time.Second |
| 33 | + // reaperListLimit caps how many Jobs a single sweep lists/deletes, so a large |
| 34 | + // accumulated backlog (TTL controller off for a long time) is drained in |
| 35 | + // bounded batches across successive sweeps instead of one giant List that |
| 36 | + // could spike agent memory or overload the API server. |
| 37 | + reaperListLimit = 100 |
| 38 | + // reapGraceSeconds is how long after a Job finishes before it's eligible for |
| 39 | + // deletion. Matched to jobTTLSeconds so behavior mirrors the TTL field: the |
| 40 | + // orchestrator's completion→log-fetch gap is seconds, so a 10-min grace |
| 41 | + // preserves the existing window for retries/manual debugging of logs. |
| 42 | + reapGraceSeconds = jobTTLSeconds |
| 43 | +) |
| 44 | + |
| 45 | +// StartReaper launches the background cleanup loop and returns immediately. The |
| 46 | +// loop runs until ctx is cancelled (agent shutdown). Safe to call once per |
| 47 | +// Runner from the agent's main wiring. |
| 48 | +func (r *Runner) StartReaper(ctx context.Context, logger *slog.Logger) { |
| 49 | + if logger == nil { |
| 50 | + logger = slog.Default() |
| 51 | + } |
| 52 | + logger = logger.With("component", "scanner_job_reaper", "namespace", r.Namespace) |
| 53 | + go func() { |
| 54 | + ticker := time.NewTicker(reaperInterval) |
| 55 | + defer ticker.Stop() |
| 56 | + logger.Info("scanner job reaper started", |
| 57 | + "interval", reaperInterval.String(), "grace_seconds", reapGraceSeconds) |
| 58 | + for { |
| 59 | + select { |
| 60 | + case <-ctx.Done(): |
| 61 | + logger.Info("scanner job reaper stopped") |
| 62 | + return |
| 63 | + case <-ticker.C: |
| 64 | + // Bound each sweep below the tick interval so an unresponsive API |
| 65 | + // server can't wedge the loop indefinitely. |
| 66 | + sweepCtx, cancel := context.WithTimeout(ctx, reaperSweepTimeout) |
| 67 | + if n := r.reapFinishedJobs(sweepCtx, logger, time.Now()); n > 0 { |
| 68 | + logger.Info("reaped finished scanner jobs", "deleted", n) |
| 69 | + } |
| 70 | + cancel() |
| 71 | + } |
| 72 | + } |
| 73 | + }() |
| 74 | +} |
| 75 | + |
| 76 | +// reapFinishedJobs deletes managed Jobs that finished more than reapGraceSeconds |
| 77 | +// before now. Returns the number deleted. Errors are logged and skipped — a |
| 78 | +// single bad Job must not stall the sweep. `now` is a parameter so tests can |
| 79 | +// drive it deterministically. |
| 80 | +func (r *Runner) reapFinishedJobs(ctx context.Context, logger *slog.Logger, now time.Time) int { |
| 81 | + jobs, err := r.Client.BatchV1().Jobs(r.Namespace).List(ctx, metav1.ListOptions{ |
| 82 | + LabelSelector: managedByLabel + "=" + managedByValue + "," + orchestratorLabel + "=" + orchestratorValue, |
| 83 | + Limit: reaperListLimit, |
| 84 | + }) |
| 85 | + if err != nil { |
| 86 | + // Suppress expected cancellation noise during agent shutdown. |
| 87 | + if ctx.Err() == nil { |
| 88 | + logger.Warn("reaper: list managed jobs failed", "error", err) |
| 89 | + } |
| 90 | + return 0 |
| 91 | + } |
| 92 | + |
| 93 | + grace := time.Duration(reapGraceSeconds) * time.Second |
| 94 | + deleted := 0 |
| 95 | + for i := range jobs.Items { |
| 96 | + job := &jobs.Items[i] |
| 97 | + finishedAt, ok := jobFinishedAt(job) |
| 98 | + if !ok { |
| 99 | + continue // still running / no terminal condition yet |
| 100 | + } |
| 101 | + if now.Sub(finishedAt) < grace { |
| 102 | + continue // within the grace window — leave it for log fetch / debugging |
| 103 | + } |
| 104 | + if err := r.deleteJobCascade(ctx, job.Name); err != nil && !apierrors.IsNotFound(err) { |
| 105 | + if ctx.Err() == nil { |
| 106 | + logger.Warn("reaper: delete job failed", "job_name", job.Name, "error", err) |
| 107 | + } |
| 108 | + continue |
| 109 | + } |
| 110 | + deleted++ |
| 111 | + logger.Info("reaper: deleted finished job", |
| 112 | + "job_name", job.Name, "finished_at", finishedAt.UTC().Format(time.RFC3339)) |
| 113 | + } |
| 114 | + return deleted |
| 115 | +} |
| 116 | + |
| 117 | +// deleteJobCascade deletes a Job with background propagation so the Job's pods |
| 118 | +// are garbage-collected along with it (the default foreground/orphan behavior |
| 119 | +// would otherwise leave the Completed pods behind). |
| 120 | +func (r *Runner) deleteJobCascade(ctx context.Context, jobName string) error { |
| 121 | + policy := metav1.DeletePropagationBackground |
| 122 | + return r.Client.BatchV1().Jobs(r.Namespace).Delete(ctx, jobName, metav1.DeleteOptions{ |
| 123 | + PropagationPolicy: &policy, |
| 124 | + }) |
| 125 | +} |
| 126 | + |
| 127 | +// jobFinishedAt returns when a Job reached a terminal (Complete/Failed) |
| 128 | +// condition. Complete Jobs carry Status.CompletionTime; Failed Jobs don't, so |
| 129 | +// we fall back to the terminal condition's LastTransitionTime. Returns false |
| 130 | +// when the Job has not finished. |
| 131 | +func jobFinishedAt(j *batchv1.Job) (time.Time, bool) { |
| 132 | + if !jobIsTerminal(j) { |
| 133 | + return time.Time{}, false |
| 134 | + } |
| 135 | + if j.Status.CompletionTime != nil { |
| 136 | + return j.Status.CompletionTime.Time, true |
| 137 | + } |
| 138 | + for _, c := range j.Status.Conditions { |
| 139 | + if c.Status != corev1.ConditionTrue { |
| 140 | + continue |
| 141 | + } |
| 142 | + if c.Type == batchv1.JobComplete || c.Type == batchv1.JobFailed { |
| 143 | + return c.LastTransitionTime.Time, true |
| 144 | + } |
| 145 | + } |
| 146 | + return time.Time{}, false |
| 147 | +} |
0 commit comments