@@ -270,6 +270,290 @@ func DefaultRegistryAPIPodTemplateSpec(labels map[string]string, configHash stri
270270 ).Build ()
271271}
272272
273+ // MergePodTemplateSpecs merges a default PodTemplateSpec with a user-provided one.
274+ // User-provided values take precedence over defaults. This allows users to customize
275+ // infrastructure concerns while ensuring sensible defaults are applied where values
276+ // are not specified.
277+ //
278+ // The merge strategy starts with the user's PodTemplateSpec and fills in defaults
279+ // only where the user hasn't specified values. This means any field the user sets
280+ // (affinity, tolerations, nodeSelector, etc.) is automatically preserved.
281+ //
282+ // Merge behavior:
283+ // - Labels/Annotations: Merged, with defaults added for missing keys
284+ // - ServiceAccountName: Default only if user hasn't specified
285+ // - Containers: Merged by name - defaults fill in missing container fields
286+ // - Volumes: Merged by name - defaults added only if not present
287+ // - All other PodSpec fields: User values preserved as-is
288+ func MergePodTemplateSpecs (defaultPTS , userPTS * corev1.PodTemplateSpec ) corev1.PodTemplateSpec {
289+ if userPTS == nil {
290+ if defaultPTS == nil {
291+ return corev1.PodTemplateSpec {}
292+ }
293+ return * defaultPTS .DeepCopy ()
294+ }
295+
296+ if defaultPTS == nil {
297+ return * userPTS .DeepCopy ()
298+ }
299+
300+ // Start with a deep copy of the user's spec - this preserves all user fields automatically
301+ result := userPTS .DeepCopy ()
302+
303+ // Merge labels: add default labels that user hasn't specified
304+ result .Labels = mergeStringMapsDefaultsFirst (defaultPTS .Labels , result .Labels )
305+
306+ // Merge annotations: add default annotations that user hasn't specified
307+ result .Annotations = mergeStringMapsDefaultsFirst (defaultPTS .Annotations , result .Annotations )
308+
309+ // Set service account only if user hasn't specified one
310+ if result .Spec .ServiceAccountName == "" {
311+ result .Spec .ServiceAccountName = defaultPTS .Spec .ServiceAccountName
312+ }
313+
314+ // Merge containers: user containers take precedence, defaults fill gaps
315+ result .Spec .Containers = mergeContainersUserFirst (defaultPTS .Spec .Containers , result .Spec .Containers )
316+
317+ // Merge init containers
318+ result .Spec .InitContainers = mergeContainersUserFirst (defaultPTS .Spec .InitContainers , result .Spec .InitContainers )
319+
320+ // Merge volumes: add default volumes that user hasn't specified
321+ result .Spec .Volumes = mergeVolumesUserFirst (defaultPTS .Spec .Volumes , result .Spec .Volumes )
322+
323+ return * result
324+ }
325+
326+ // mergeContainersUserFirst merges containers where user containers take precedence.
327+ // User containers are preserved, and default container fields fill in gaps.
328+ func mergeContainersUserFirst (defaults , user []corev1.Container ) []corev1.Container {
329+ if len (user ) == 0 {
330+ return defaults
331+ }
332+ if len (defaults ) == 0 {
333+ return user
334+ }
335+
336+ // Create a map of default containers by name
337+ defaultMap := make (map [string ]corev1.Container )
338+ for _ , c := range defaults {
339+ defaultMap [c .Name ] = c
340+ }
341+
342+ // Start with user containers, filling in defaults where needed
343+ result := make ([]corev1.Container , 0 , len (user )+ len (defaults ))
344+ merged := make (map [string ]bool )
345+
346+ for _ , userContainer := range user {
347+ if defaultContainer , exists := defaultMap [userContainer .Name ]; exists {
348+ // Merge: user values take precedence, defaults fill gaps
349+ result = append (result , mergeContainer (defaultContainer , userContainer ))
350+ merged [userContainer .Name ] = true
351+ } else {
352+ // User container with no default - keep as-is
353+ result = append (result , userContainer )
354+ }
355+ }
356+
357+ // Add default containers that user didn't specify
358+ for _ , defaultContainer := range defaults {
359+ if ! merged [defaultContainer .Name ] {
360+ result = append (result , defaultContainer )
361+ }
362+ }
363+
364+ return result
365+ }
366+
367+ // mergeContainer merges a default container with a user container.
368+ // User values take precedence; defaults fill in where user hasn't specified.
369+ func mergeContainer (defaultContainer , userContainer corev1.Container ) corev1.Container {
370+ // Start with user container - preserves all user-specified fields
371+ result := userContainer
372+
373+ // Fill in defaults only where user hasn't specified
374+ if result .Image == "" {
375+ result .Image = defaultContainer .Image
376+ }
377+ if len (result .Command ) == 0 {
378+ result .Command = defaultContainer .Command
379+ }
380+ if len (result .Args ) == 0 {
381+ result .Args = defaultContainer .Args
382+ }
383+ if result .WorkingDir == "" {
384+ result .WorkingDir = defaultContainer .WorkingDir
385+ }
386+ if isResourcesEmpty (result .Resources ) {
387+ result .Resources = defaultContainer .Resources
388+ }
389+ if result .LivenessProbe == nil {
390+ result .LivenessProbe = defaultContainer .LivenessProbe
391+ }
392+ if result .ReadinessProbe == nil {
393+ result .ReadinessProbe = defaultContainer .ReadinessProbe
394+ }
395+ if result .StartupProbe == nil {
396+ result .StartupProbe = defaultContainer .StartupProbe
397+ }
398+ if result .SecurityContext == nil {
399+ result .SecurityContext = defaultContainer .SecurityContext
400+ }
401+ if result .ImagePullPolicy == "" {
402+ result .ImagePullPolicy = defaultContainer .ImagePullPolicy
403+ }
404+
405+ // Merge slices: add defaults that user hasn't specified
406+ result .Ports = mergePortsUserFirst (defaultContainer .Ports , result .Ports )
407+ result .Env = mergeEnvVarsUserFirst (defaultContainer .Env , result .Env )
408+ result .VolumeMounts = mergeVolumeMountsUserFirst (defaultContainer .VolumeMounts , result .VolumeMounts )
409+
410+ return result
411+ }
412+
413+ // mergeVolumesUserFirst merges volumes where user volumes take precedence.
414+ func mergeVolumesUserFirst (defaults , user []corev1.Volume ) []corev1.Volume {
415+ if len (user ) == 0 {
416+ return defaults
417+ }
418+ if len (defaults ) == 0 {
419+ return user
420+ }
421+
422+ // Create a map of user volumes by name
423+ userMap := make (map [string ]bool )
424+ for _ , v := range user {
425+ userMap [v .Name ] = true
426+ }
427+
428+ // Start with user volumes
429+ result := make ([]corev1.Volume , 0 , len (user )+ len (defaults ))
430+ result = append (result , user ... )
431+
432+ // Add default volumes that user hasn't specified
433+ for _ , defaultVolume := range defaults {
434+ if ! userMap [defaultVolume .Name ] {
435+ result = append (result , defaultVolume )
436+ }
437+ }
438+
439+ return result
440+ }
441+
442+ // mergePortsUserFirst merges ports where user ports take precedence.
443+ func mergePortsUserFirst (defaults , user []corev1.ContainerPort ) []corev1.ContainerPort {
444+ if len (user ) == 0 {
445+ return defaults
446+ }
447+ if len (defaults ) == 0 {
448+ return user
449+ }
450+
451+ // Track user ports by name and port number
452+ userByName := make (map [string ]bool )
453+ userByPort := make (map [int32 ]bool )
454+ for _ , p := range user {
455+ if p .Name != "" {
456+ userByName [p .Name ] = true
457+ }
458+ userByPort [p .ContainerPort ] = true
459+ }
460+
461+ // Start with user ports
462+ result := make ([]corev1.ContainerPort , 0 , len (user )+ len (defaults ))
463+ result = append (result , user ... )
464+
465+ // Add default ports that user hasn't specified
466+ for _ , defaultPort := range defaults {
467+ nameConflict := defaultPort .Name != "" && userByName [defaultPort .Name ]
468+ portConflict := userByPort [defaultPort .ContainerPort ]
469+ if ! nameConflict && ! portConflict {
470+ result = append (result , defaultPort )
471+ }
472+ }
473+
474+ return result
475+ }
476+
477+ // mergeEnvVarsUserFirst merges env vars where user env vars take precedence.
478+ func mergeEnvVarsUserFirst (defaults , user []corev1.EnvVar ) []corev1.EnvVar {
479+ if len (user ) == 0 {
480+ return defaults
481+ }
482+ if len (defaults ) == 0 {
483+ return user
484+ }
485+
486+ // Create a map of user env vars by name
487+ userMap := make (map [string ]bool )
488+ for _ , e := range user {
489+ userMap [e .Name ] = true
490+ }
491+
492+ // Start with user env vars
493+ result := make ([]corev1.EnvVar , 0 , len (user )+ len (defaults ))
494+ result = append (result , user ... )
495+
496+ // Add default env vars that user hasn't specified
497+ for _ , defaultEnv := range defaults {
498+ if ! userMap [defaultEnv .Name ] {
499+ result = append (result , defaultEnv )
500+ }
501+ }
502+
503+ return result
504+ }
505+
506+ // mergeVolumeMountsUserFirst merges volume mounts where user mounts take precedence.
507+ func mergeVolumeMountsUserFirst (defaults , user []corev1.VolumeMount ) []corev1.VolumeMount {
508+ if len (user ) == 0 {
509+ return defaults
510+ }
511+ if len (defaults ) == 0 {
512+ return user
513+ }
514+
515+ // Create a map of user volume mounts by name
516+ userMap := make (map [string ]bool )
517+ for _ , m := range user {
518+ userMap [m .Name ] = true
519+ }
520+
521+ // Start with user mounts
522+ result := make ([]corev1.VolumeMount , 0 , len (user )+ len (defaults ))
523+ result = append (result , user ... )
524+
525+ // Add default mounts that user hasn't specified
526+ for _ , defaultMount := range defaults {
527+ if ! userMap [defaultMount .Name ] {
528+ result = append (result , defaultMount )
529+ }
530+ }
531+
532+ return result
533+ }
534+
535+ // mergeStringMapsDefaultsFirst merges string maps where user values override defaults.
536+ // Returns a map with all default keys, plus any additional user keys, with user values taking precedence.
537+ func mergeStringMapsDefaultsFirst (defaults , user map [string ]string ) map [string ]string {
538+ if len (defaults ) == 0 && len (user ) == 0 {
539+ return nil
540+ }
541+
542+ result := make (map [string ]string )
543+ for k , v := range defaults {
544+ result [k ] = v
545+ }
546+ for k , v := range user {
547+ result [k ] = v // User values override defaults
548+ }
549+ return result
550+ }
551+
552+ // isResourcesEmpty checks if ResourceRequirements are empty.
553+ func isResourcesEmpty (resources corev1.ResourceRequirements ) bool {
554+ return len (resources .Requests ) == 0 && len (resources .Limits ) == 0
555+ }
556+
273557// findContainerByName finds a container by name in a slice of containers.
274558// Returns a pointer to the container if found, nil otherwise.
275559func findContainerByName (containers []corev1.Container , name string ) * corev1.Container {
0 commit comments