@@ -252,6 +252,186 @@ func TestProcessSyncEvents_DedupeContainers(t *testing.T) {
252252 assert .Equal (t , 1 , poster .clusterRecordCount , "CreateClusterJob should receive only 1 record" )
253253}
254254
255+ func TestMakeSyncRecords_RolloutPicksNewestRunningDigest (t * testing.T ) {
256+ t .Parallel ()
257+ oldDigest := "sha256:old"
258+ newDigest := "sha256:new"
259+ now := time .Now ()
260+
261+ // Same deployment_name (default/test-deploy/app) with two digests, as
262+ // happens mid-rollout when old and new pods are both Running.
263+ oldPod := makeTestPod ("app" , "test-deploy-old" , oldDigest , "ReplicaSet" )
264+ oldPod .Name = "pod-old"
265+ oldPod .CreationTimestamp = metav1 .NewTime (now .Add (- 10 * time .Minute ))
266+
267+ newPod := makeTestPod ("app" , "test-deploy-new" , newDigest , "ReplicaSet" )
268+ newPod .Name = "pod-new"
269+ newPod .CreationTimestamp = metav1 .NewTime (now )
270+
271+ ctrl := newTestController (& mockPoster {})
272+ ctrl .workloadResolver = & mockResolver {name : "test-deploy" }
273+
274+ // Feed old first so we exercise replacement, not just first-seen.
275+ records := ctrl .makeSyncRecords (context .Background (), []any {oldPod , newPod })
276+ require .Len (t , records , 1 , "rollout should collapse to one record per deployment_name" )
277+ assert .Equal (t , newDigest , records [0 ].Digest , "newest running pod digest should win" )
278+ assert .Equal (t , "default/test-deploy/app" , records [0 ].DeploymentName )
279+ }
280+
281+ func TestMakeSyncRecords_NewestWinsRegardlessOfOrder (t * testing.T ) {
282+ t .Parallel ()
283+ oldDigest := "sha256:old"
284+ newDigest := "sha256:new"
285+ now := time .Now ()
286+
287+ oldPod := makeTestPod ("app" , "test-deploy-old" , oldDigest , "ReplicaSet" )
288+ oldPod .Name = "pod-old"
289+ oldPod .CreationTimestamp = metav1 .NewTime (now .Add (- 10 * time .Minute ))
290+
291+ newPod := makeTestPod ("app" , "test-deploy-new" , newDigest , "ReplicaSet" )
292+ newPod .Name = "pod-new"
293+ newPod .CreationTimestamp = metav1 .NewTime (now )
294+
295+ ctrl := newTestController (& mockPoster {})
296+ ctrl .workloadResolver = & mockResolver {name : "test-deploy" }
297+
298+ // Feed newest first, then older. The older pod must not overwrite.
299+ records := ctrl .makeSyncRecords (context .Background (), []any {newPod , oldPod })
300+ require .Len (t , records , 1 )
301+ assert .Equal (t , newDigest , records [0 ].Digest , "newest wins even when the older pod is processed last" )
302+ }
303+
304+ func TestMakeSyncRecords_PrefersRunningOverNewerTerminal (t * testing.T ) {
305+ t .Parallel ()
306+ runningDigest := "sha256:running"
307+ terminalDigest := "sha256:terminal"
308+ now := time .Now ()
309+
310+ // Running pod is OLDER, terminal Job pod is NEWER. Running should still
311+ // win because a running pod reflects current cluster state.
312+ runningPod := makeTestPod ("app" , "test-job" , runningDigest , "Job" )
313+ runningPod .Name = "pod-running"
314+ runningPod .Status .Phase = corev1 .PodRunning
315+ runningPod .CreationTimestamp = metav1 .NewTime (now .Add (- 10 * time .Minute ))
316+
317+ terminalPod := makeTestPod ("app" , "test-job" , terminalDigest , "Job" )
318+ terminalPod .Name = "pod-terminal"
319+ terminalPod .Status .Phase = corev1 .PodSucceeded
320+ terminalPod .CreationTimestamp = metav1 .NewTime (now )
321+
322+ ctrl := newTestController (& mockPoster {})
323+ ctrl .workloadResolver = & mockResolver {name : "test-job" }
324+
325+ records := ctrl .makeSyncRecords (context .Background (), []any {runningPod , terminalPod })
326+ require .Len (t , records , 1 )
327+ assert .Equal (t , runningDigest , records [0 ].Digest , "running pod should win over a newer terminal pod" )
328+ }
329+
330+ func TestMakeSyncRecords_TerminalPicksNewest (t * testing.T ) {
331+ t .Parallel ()
332+ oldDigest := "sha256:old"
333+ newDigest := "sha256:new"
334+ now := time .Now ()
335+
336+ // Two terminal Job pods (no running pod). Newest terminal wins.
337+ oldPod := makeTestPod ("app" , "test-job" , oldDigest , "Job" )
338+ oldPod .Name = "pod-old"
339+ oldPod .Status .Phase = corev1 .PodSucceeded
340+ oldPod .CreationTimestamp = metav1 .NewTime (now .Add (- 10 * time .Minute ))
341+
342+ newPod := makeTestPod ("app" , "test-job" , newDigest , "Job" )
343+ newPod .Name = "pod-new"
344+ newPod .Status .Phase = corev1 .PodSucceeded
345+ newPod .CreationTimestamp = metav1 .NewTime (now )
346+
347+ ctrl := newTestController (& mockPoster {})
348+ ctrl .workloadResolver = & mockResolver {name : "test-job" }
349+
350+ records := ctrl .makeSyncRecords (context .Background (), []any {oldPod , newPod })
351+ require .Len (t , records , 1 )
352+ assert .Equal (t , newDigest , records [0 ].Digest , "newest terminal pod digest should win" )
353+ }
354+
355+ func TestMakeSyncRecords_DistinctDeploymentNamesNotCollapsed (t * testing.T ) {
356+ t .Parallel ()
357+ now := time .Now ()
358+
359+ // Different container names produce different deployment_names, so both
360+ // must be kept even though they share a pod-creation window.
361+ podA := makeTestPod ("app" , "test-deploy-1" , "sha256:aaa" , "ReplicaSet" )
362+ podA .Name = "pod-a"
363+ podA .CreationTimestamp = metav1 .NewTime (now )
364+
365+ podB := makeTestPod ("sidecar" , "test-deploy-2" , "sha256:bbb" , "ReplicaSet" )
366+ podB .Name = "pod-b"
367+ podB .CreationTimestamp = metav1 .NewTime (now )
368+
369+ ctrl := newTestController (& mockPoster {})
370+ ctrl .workloadResolver = & mockResolver {name : "test-deploy" }
371+
372+ records := ctrl .makeSyncRecords (context .Background (), []any {podA , podB })
373+ require .Len (t , records , 2 , "distinct deployment_names should not be collapsed" )
374+
375+ byName := map [string ]string {}
376+ for _ , r := range records {
377+ byName [r .DeploymentName ] = r .Digest
378+ }
379+ assert .Equal (t , "sha256:aaa" , byName ["default/test-deploy/app" ])
380+ assert .Equal (t , "sha256:bbb" , byName ["default/test-deploy/sidecar" ])
381+ }
382+
383+ // TestMakeSyncRecords_NoDuplicateDeploymentNames is the regression guard for the
384+ // 400 "Duplicate deployment_name" error: under a messy real-world mix (a rollout
385+ // with multiple replicas of two digests, plus a second workload), every submitted
386+ // record must have a unique deployment_name.
387+ func TestMakeSyncRecords_NoDuplicateDeploymentNames (t * testing.T ) {
388+ t .Parallel ()
389+ now := time .Now ()
390+
391+ mkPod := func (name , container , digest string , phase corev1.PodPhase , created time.Time ) * corev1.Pod {
392+ p := makeTestPod (container , "test-deploy-rs" , digest , "ReplicaSet" )
393+ p .Name = name
394+ p .Status .Phase = phase
395+ p .CreationTimestamp = metav1 .NewTime (created )
396+ return p
397+ }
398+
399+ old := now .Add (- 10 * time .Minute )
400+ pods := []any {
401+ // Workload A ("app"): rollout with 2 old replicas + 2 new replicas.
402+ mkPod ("a-old-1" , "app" , "sha256:old" , corev1 .PodRunning , old ),
403+ mkPod ("a-old-2" , "app" , "sha256:old" , corev1 .PodRunning , old ),
404+ mkPod ("a-new-1" , "app" , "sha256:new" , corev1 .PodRunning , now ),
405+ mkPod ("a-new-2" , "app" , "sha256:new" , corev1 .PodRunning , now ),
406+ // Workload B ("sidecar"): single stable version with 3 replicas.
407+ mkPod ("b-1" , "sidecar" , "sha256:bbb" , corev1 .PodRunning , now ),
408+ mkPod ("b-2" , "sidecar" , "sha256:bbb" , corev1 .PodRunning , now ),
409+ mkPod ("b-3" , "sidecar" , "sha256:bbb" , corev1 .PodRunning , now ),
410+ }
411+
412+ ctrl := newTestController (& mockPoster {})
413+ ctrl .workloadResolver = & mockResolver {name : "test-deploy" }
414+
415+ records := ctrl .makeSyncRecords (context .Background (), pods )
416+
417+ // Ensure no duplicate deployment_names are submitted.
418+ seen := map [string ]bool {}
419+ for _ , r := range records {
420+ assert .Falsef (t , seen [r .DeploymentName ],
421+ "duplicate deployment_name submitted: %s" , r .DeploymentName )
422+ seen [r .DeploymentName ] = true
423+ }
424+
425+ // And the winning digest for the rolled-out workload is the newest.
426+ require .Len (t , records , 2 , "two distinct deployment_names expected" )
427+ byName := map [string ]string {}
428+ for _ , r := range records {
429+ byName [r .DeploymentName ] = r .Digest
430+ }
431+ assert .Equal (t , "sha256:new" , byName ["default/test-deploy/app" ], "newest digest should win for the rollout" )
432+ assert .Equal (t , "sha256:bbb" , byName ["default/test-deploy/sidecar" ])
433+ }
434+
255435func TestProcessSyncEvents_409Conflict (t * testing.T ) {
256436 t .Parallel ()
257437 digest := "sha256:abc123"
0 commit comments