Skip to content

Commit fc1bbfb

Browse files
committed
fix: unique deployment names for cluster job
1 parent ca19808 commit fc1bbfb

2 files changed

Lines changed: 294 additions & 18 deletions

File tree

internal/controller/reporting.go

Lines changed: 114 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,11 @@ func (c *Controller) processSyncEvents(ctx context.Context, syncClusterPods []an
163163
)
164164
}
165165

166+
// Log per-deployment at INFO now that the job is queued, matching the
167+
// individual "Posted record" path so startup sync has equivalent
168+
// per-deployment visibility. Records rejected at submission are excluded.
169+
logSyncedRecords(jobResp, syncRecords)
170+
166171
// Wait for job completion with a timeout to prevent indefinite startup delay.
167172
jobCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
168173
defer cancel()
@@ -197,9 +202,20 @@ func (c *Controller) processSyncEvents(ctx context.Context, syncClusterPods []an
197202
return nil
198203
}
199204

205+
// syncCandidate is a single container observed on a pod that is eligible to
206+
// become the deployment record for a given deployment_name during startup sync.
207+
type syncCandidate struct {
208+
pod *corev1.Pod
209+
container corev1.Container
210+
wlName string
211+
}
212+
200213
func (c *Controller) makeSyncRecords(ctx context.Context, syncClusterPods []any) []*deploymentrecord.Record {
201-
seenSyncRecords := make(map[string]bool)
202-
var syncRecords []*deploymentrecord.Record
214+
// The API requires a unique set of deployment_names for a snapshot job.
215+
// During a rollout the same deployment_name can appear with multiple digests
216+
// (old and new pods running at once), so we must collapse to a single digest per workload +
217+
// container name. We pick the newest running pod so the snapshot reflects the rollout target.
218+
winners := make(map[string]syncCandidate)
203219
for _, p := range syncClusterPods {
204220
pod, ok := p.(*corev1.Pod)
205221
if !ok {
@@ -227,34 +243,75 @@ func (c *Controller) makeSyncRecords(ctx context.Context, syncClusterPods []any)
227243
allContainers = append(allContainers, pod.Spec.Containers...)
228244
allContainers = append(allContainers, pod.Spec.InitContainers...)
229245

230-
// Filter out containers already in syncRecords
231-
var newContainers []corev1.Container
246+
// Dedupe to one record per deployment_name. On a digest conflict
247+
// (rollout in progress) the newest running pod wins.
232248
for _, container := range allContainers {
233249
dn := getARDeploymentName(pod, container, c.cfg.Template, wl.Name)
234250
digest := getContainerDigest(pod, container.Name)
235251
if dn == "" || digest == "" {
236252
continue
237253
}
238-
key := getCacheKey(EventCreated, dn, digest)
239-
if seenSyncRecords[key] {
254+
255+
existing, exists := winners[dn]
256+
if !exists {
257+
winners[dn] = syncCandidate{pod: pod, container: container, wlName: wl.Name}
240258
continue
241259
}
242-
seenSyncRecords[key] = true
243-
newContainers = append(newContainers, container)
244-
}
245260

246-
if len(newContainers) == 0 {
247-
continue
261+
// Same deployment_name with the same digest is just another replica
262+
// of the same version. The submitted record (deployment_name, digest,
263+
// image) is identical regardless of which pod we pick, so keep the
264+
// one we already have.
265+
existingDigest := getContainerDigest(existing.pod, existing.container.Name)
266+
if existingDigest == digest {
267+
continue
268+
}
269+
270+
// Collision with a different digest (e.g. rollout in progress).
271+
// Keep the newest running pod's digest and log the decision so the
272+
// rollout is visible.
273+
if isPreferredSyncPod(pod, existing.pod) {
274+
slog.Info("Multiple digests observed for deployment_name during sync, choosing newest running",
275+
"deployment_name", dn,
276+
"chosen_pod", pod.Name,
277+
"chosen_digest", digest,
278+
"replaced_pod", existing.pod.Name,
279+
"replaced_digest", existingDigest,
280+
)
281+
winners[dn] = syncCandidate{pod: pod, container: container, wlName: wl.Name}
282+
} else {
283+
slog.Info("Multiple digests observed for deployment_name during sync, keeping newest running",
284+
"deployment_name", dn,
285+
"kept_pod", existing.pod.Name,
286+
"kept_digest", existingDigest,
287+
"skipped_pod", pod.Name,
288+
"skipped_digest", digest,
289+
)
290+
}
248291
}
292+
}
249293

250-
// Only gather aggregate metadata if there are new containers
251-
aggPodMetadata := c.metadataAggregator.BuildAggregatePodMetadata(ctx, podToPartialMetadata(pod))
294+
// Build records from the winning candidates. Iterate in sorted
295+
// deployment_name order for deterministic output, and cache aggregate
296+
// metadata per pod so pods hosting multiple winners aren't recomputed.
297+
aggCache := make(map[*corev1.Pod]*metadata.AggregatePodMetadata)
298+
dns := make([]string, 0, len(winners))
299+
for dn := range winners {
300+
dns = append(dns, dn)
301+
}
302+
slices.Sort(dns)
252303

253-
for _, container := range newContainers {
254-
record := c.buildRecord(pod, container, EventCreated, wl.Name, aggPodMetadata)
255-
if record != nil {
256-
syncRecords = append(syncRecords, record)
257-
}
304+
syncRecords := make([]*deploymentrecord.Record, 0, len(winners))
305+
for _, dn := range dns {
306+
cand := winners[dn]
307+
agg, ok := aggCache[cand.pod]
308+
if !ok {
309+
agg = c.metadataAggregator.BuildAggregatePodMetadata(ctx, podToPartialMetadata(cand.pod))
310+
aggCache[cand.pod] = agg
311+
}
312+
record := c.buildRecord(cand.pod, cand.container, EventCreated, cand.wlName, agg)
313+
if record != nil {
314+
syncRecords = append(syncRecords, record)
258315
}
259316
}
260317

@@ -264,6 +321,45 @@ func (c *Controller) makeSyncRecords(ctx context.Context, syncClusterPods []any)
264321
return syncRecords
265322
}
266323

324+
// isPreferredSyncPod reports whether candidate should replace current as the
325+
// chosen pod for a deployment_name during startup sync. Running pods are
326+
// preferred over terminal pods; among pods in the same category the most
327+
// recently created pod wins. This makes the startup snapshot reflect the
328+
// rollout target (newest running digest) when multiple digests coexist.
329+
func isPreferredSyncPod(candidate, current *corev1.Pod) bool {
330+
candRunning := candidate.Status.Phase == corev1.PodRunning
331+
curRunning := current.Status.Phase == corev1.PodRunning
332+
if candRunning != curRunning {
333+
return candRunning
334+
}
335+
return candidate.CreationTimestamp.Time.After(current.CreationTimestamp.Time)
336+
}
337+
338+
// logSyncedRecords emits one log line per accepted record after a cluster job
339+
// has been queued.
340+
func logSyncedRecords(jobResp *deploymentrecord.JobResponse, records []*deploymentrecord.Record) {
341+
rejected := make(map[string]bool, len(jobResp.Errors))
342+
for _, e := range jobResp.Errors {
343+
rejected[e.Name] = true
344+
}
345+
346+
for _, r := range records {
347+
if rejected[r.Name] {
348+
continue
349+
}
350+
slog.Info("Submitted record for sync",
351+
"event_type", EventCreated,
352+
"name", r.Name,
353+
"deployment_name", r.DeploymentName,
354+
"status", r.Status,
355+
"digest", r.Digest,
356+
"runtime_risks", r.RuntimeRisks,
357+
"tags", r.Tags,
358+
"job_id", jobResp.JobID,
359+
)
360+
}
361+
}
362+
267363
// fillCachesFromSubmitted populates the observedDeployments cache from the
268364
// records we submitted, without waiting for a response. Used when we can't
269365
// get a response (409 conflict, wait timeout, job failure).

internal/controller/reporting_test.go

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
255435
func TestProcessSyncEvents_409Conflict(t *testing.T) {
256436
t.Parallel()
257437
digest := "sha256:abc123"

0 commit comments

Comments
 (0)