Skip to content

fix: preserve DCGM service compatibility - #2918

Open
rahulait wants to merge 1 commit into
NVIDIA:mainfrom
rahulait:fix/preserve-dcgm-service-compatibility
Open

rahulait wants to merge 1 commit into
NVIDIA:mainfrom
rahulait:fix/preserve-dcgm-service-compatibility

Conversation

@rahulait

@rahulait rahulait commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Description

Partially fixes #2917

Restore stable DCGM and DCGM Exporter identities for GPUCluster/DRA deployments while retaining the released DRA-specific Services.

Changes

  • Add DRA compatibility Services:
    • nvidia-dcgm
    • nvidia-dcgm-exporter
  • Retain:
    • nvidia-dcgm-dra
    • nvidia-dcgm-exporter-dra
  • Add common labels to DaemonSets and pod templates:
    • nvidia.com/gpu-operator.dcgm: "true"
    • nvidia.com/gpu-operator.dcgm-exporter: "true"
  • Prevent ClusterPolicy custom labels from overriding the common labels.
  • Preserve Kubernetes-assigned Service fields during GPUCluster updates, including ClusterIPs and NodePorts.
  • Annotate only nvidia-dcgm-exporter for Prometheus scraping to avoid duplicate targets.
  • Add tests and update golden manifests.

Checklist

  • No secrets, sensitive information, or unrelated changes
  • Lint checks passing (make lint)
  • Generated assets in-sync (make validate-generated-assets)
  • Go mod artifacts in-sync (make validate-modules)
  • Test cases are added for new code paths

Testing

devin-ai-integration[bot]

This comment was marked as resolved.

@rahulait
rahulait force-pushed the fix/preserve-dcgm-service-compatibility branch from b7c20ed to 3cd2c28 Compare September 17, 2026 18:32
devin-ai-integration[bot]

This comment was marked as resolved.

@rahulait
rahulait force-pushed the fix/preserve-dcgm-service-compatibility branch from 3cd2c28 to 4d4b829 Compare September 17, 2026 19:06
devin-ai-integration[bot]

This comment was marked as resolved.

Signed-off-by: Rahul Sharma <rahulsharm@nvidia.com>
@rahulait
rahulait force-pushed the fix/preserve-dcgm-service-compatibility branch from 4d4b829 to 3518aac Compare September 17, 2026 19:13

// mergeService preserves fields allocated by the API server. Clearing these fields in a full
// update is rejected because their values are immutable or must remain allocated.
func (s *stateSkel) mergeService(updated, current *unstructured.Unstructured) error {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question -- why is this needed now when it wasn't needed before? Is it because this commit is introducing a diff in the nvidia-dcgm-exporter-dra Service object?

@rahulait rahulait Sep 17, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was flagged by LLMs. I believe we had this bug introduced recently but never caught it.

non-dra path uses:

logger.Info("Found Resource, updating...")
obj.ResourceVersion = found.ResourceVersion
obj.Spec.ClusterIP = found.Spec.ClusterIP
err = n.client.Update(ctx, obj)

It sets resourceVersion and clusterip allocated before calling update().

However, GPUCluster path uses generic unstructured reconciler which only preserves resourceVersion but not the clusterIP. Kubernetes can reject the update if clusterIP is omitted. This might be an existing bug in v26.7.0 not yet reported. Once service is created and ip is assigned, future reconcile updates might be failing but its not getting affected as it exists, just not getting updated.

@rahulait
rahulait marked this pull request as ready for review September 17, 2026 20:49
@rahulait rahulait self-assigned this Sep 17, 2026
@coderabbitai

coderabbitai Bot commented Sep 17, 2026

Copy link
Copy Markdown

Review Change StackReview Change Stack

📝 Walkthrough

Walkthrough

The change adds reserved DCGM and DCGM Exporter labels to DaemonSets and pod templates. It prevents user labels from overriding those labels. GPUCluster manifests add stable DCGM and exporter Services while retaining DRA-specific Services. Service merging now preserves API-assigned networking fields, node ports, and health-check node ports. Tests cover labels, Services, selectors, annotations, overrides, and merge behavior.

Priority: ➖ Normal

Severity of issue fixed: Medium

Merge Risk: 🟡 Moderate · up to 3518a

A GPUCluster upgrade can remove legacy DCGM or exporter endpoints before DRA pods are ready, temporarily interrupting hostengine or metrics access. Preserve current selectors for existing Services before merging.


Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: QUIET

Plan: Enterprise

Run ID: 6c4f18da-82df-4dc8-89bc-2805492a802d

📥 Commits

Reviewing files that changed from the base of the PR and between 93c0ff5 and 3518aac.

⛔ Files ignored due to path filters (4)
  • internal/state/testdata/golden/gpucluster-dcgm-exporter-embedded.yaml is excluded by !**/testdata/**
  • internal/state/testdata/golden/gpucluster-dcgm-exporter-pod-metadata.yaml is excluded by !**/testdata/**
  • internal/state/testdata/golden/gpucluster-dcgm-exporter-remote-engine.yaml is excluded by !**/testdata/**
  • internal/state/testdata/golden/gpucluster-dcgm.yaml is excluded by !**/testdata/**
📒 Files selected for processing (13)
  • assets/state-dcgm-exporter/0800_daemonset.yaml
  • assets/state-dcgm/0400_dcgm.yml
  • controllers/object_controls.go
  • controllers/object_controls_test.go
  • internal/state/dcgm_exporter_test.go
  • internal/state/dcgm_test.go
  • internal/state/state_skel.go
  • internal/state/state_skel_test.go
  • manifests/state-dcgm-exporter/0490_service.yaml
  • manifests/state-dcgm-exporter/0500_service.yaml
  • manifests/state-dcgm-exporter/0700_daemonset.yaml
  • manifests/state-dcgm/0500_daemonset.yaml
  • manifests/state-dcgm/0550_service.yaml
💤 Files with no reviewable changes (1)
  • manifests/state-dcgm-exporter/0500_service.yaml

Included review availability: Your plan provides up to 12 included reviews per hour; 11 remain after this review.

Comment on lines 443 to 528
if gvk.Group == "" && gvk.Kind == "ServiceAccount" {
return s.mergeServiceAccount(updated, current)
}
if gvk.Group == "" && gvk.Kind == "Service" {
return s.mergeService(updated, current)
}
return nil
}

// mergeService preserves fields allocated by the API server. Clearing these fields in a full
// update is rejected because their values are immutable or must remain allocated.
func (s *stateSkel) mergeService(updated, current *unstructured.Unstructured) error {
updatedService := &corev1.Service{}
if err := runtime.DefaultUnstructuredConverter.FromUnstructured(updated.Object, updatedService); err != nil {
return fmt.Errorf("failed to convert updated Service: %w", err)
}
currentService := &corev1.Service{}
if err := runtime.DefaultUnstructuredConverter.FromUnstructured(current.Object, currentService); err != nil {
return fmt.Errorf("failed to convert current Service: %w", err)
}

if updatedService.Spec.ClusterIP == "" && len(updatedService.Spec.ClusterIPs) == 0 {
updatedService.Spec.ClusterIP = currentService.Spec.ClusterIP
updatedService.Spec.ClusterIPs = currentService.Spec.ClusterIPs
}
if len(updatedService.Spec.IPFamilies) == 0 {
updatedService.Spec.IPFamilies = currentService.Spec.IPFamilies
}
if updatedService.Spec.IPFamilyPolicy == nil {
updatedService.Spec.IPFamilyPolicy = currentService.Spec.IPFamilyPolicy
}

if serviceAllocatesNodePorts(updatedService) {
for i := range updatedService.Spec.Ports {
if updatedService.Spec.Ports[i].NodePort != 0 {
continue
}
if currentPort := findMatchingServicePort(updatedService.Spec.Ports[i], currentService.Spec.Ports); currentPort != nil {
updatedService.Spec.Ports[i].NodePort = currentPort.NodePort
}
}
}
if updatedService.Spec.Type == corev1.ServiceTypeLoadBalancer &&
updatedService.Spec.ExternalTrafficPolicy == corev1.ServiceExternalTrafficPolicyLocal &&
updatedService.Spec.HealthCheckNodePort == 0 {
updatedService.Spec.HealthCheckNodePort = currentService.Spec.HealthCheckNodePort
}

merged, err := runtime.DefaultUnstructuredConverter.ToUnstructured(updatedService)
if err != nil {
return fmt.Errorf("failed to convert merged Service: %w", err)
}
updated.Object = merged
return nil
}

func serviceAllocatesNodePorts(service *corev1.Service) bool {
if service.Spec.Type == corev1.ServiceTypeNodePort {
return true
}
return service.Spec.Type == corev1.ServiceTypeLoadBalancer &&
(service.Spec.AllocateLoadBalancerNodePorts == nil || *service.Spec.AllocateLoadBalancerNodePorts)
}

func findMatchingServicePort(updated corev1.ServicePort, current []corev1.ServicePort) *corev1.ServicePort {
for i := range current {
if updated.Name != "" && updated.Name == current[i].Name {
return &current[i]
}
if updated.Name == "" && current[i].Name == "" && servicePortProtocol(updated) == servicePortProtocol(current[i]) {
return &current[i]
}
}
return nil
}

func servicePortProtocol(port corev1.ServicePort) corev1.Protocol {
if port.Protocol == "" {
return corev1.ProtocolTCP
}
return port.Protocol
}

// For Service Account, keep secrets if exists
func (s *stateSkel) mergeServiceAccount(updated, current *unstructured.Unstructured) error {
curSecrets, ok, err := unstructured.NestedSlice(current.Object, "secrets")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

sed -n '430,540p' internal/state/state_skel.go
sed -n '1,100p' manifests/state-dcgm/0550_service.yaml
sed -n '1,120p' manifests/state-dcgm-exporter/0490_service.yaml
sed -n '4910,5000p' controllers/object_controls.go
rg -n 'nvidia-dcgm(-exporter)?|selector|mergeObjects|mergeService' internal/state controllers manifests assets | head -240

Repository: NVIDIA/gpu-operator

Length of output: 31109


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- state skeleton reconciliation ---'
sed -n '280,390p' internal/state/state_skel.go
printf '%s\n' '--- state skeleton declarations and callers ---'
rg -n -C 3 'NewState|stateSkel|Reconcile|mergeObjects|GPUCluster|ClusterPolicy|State\(' internal/state controllers | head -320
printf '%s\n' '--- DCGM state definitions and registration ---'
rg -n -C 5 'func (.*DCGM|func DCGM|DCGMExporter|dcgm|state-dcgm|state-dcgm-exporter' internal/state controllers | head -360
printf '%s\n' '--- Service update paths and selector preservation ---'
rg -n -C 5 'Spec\.Selector|Selector.*current|current.*Selector|preserve.*selector|selector.*preserve|Service\(' internal/state controllers | head -360
printf '%s\n' '--- rollout/upgrade requirement evidence ---'
rg -n -i -C 4 'selector.*(preserv|rollout|upgrade)|preserv.*selector|endpoint|downtime|availability|DCGM' . --glob '!internal/state/testdata/golden/**' --glob '!vendor/**' | head -360

Repository: NVIDIA/gpu-operator

Length of output: 50375


🤖 get_repo_knowledge executed:

get_repo_knowledge NVIDIA/gpu-operator /tmp/coderabbit-repo-knowledge/nvidia-gpu-operator-eed98df4/conventions

Length of output: 4401


🏁 Script executed:

set -e
sed -n '280,390p' internal/state/state_skel.go
rg -n -C 4 'stateSkel|mergeObjects|GPUCluster|state-dcgm|state-dcgm-exporter|Spec\.Selector|preserve.*selector|selector.*preserv' internal/state controllers

Repository: NVIDIA/gpu-operator

Length of output: 50376


🏁 Script executed:

set -e
printf '%s\n' '--- changed state skeleton diff ---'
git diff -- internal/state/state_skel.go
printf '%s\n' '--- state manager/controller GPUCluster entrypoints ---'
rg -n -C 8 'GPUCluster|SyncState|Sync\(|NewStateDCGM|NewStateDCGMExporter|newStates|stateManager|Reconcile' controllers/state_manager.go internal/state/manager.go internal/state/*.go | head -500
printf '%s\n' '--- focused selector tests and requirements ---'
rg -n -C 8 'selector|endpoint|rollout|preserv|DRA' internal/state/state_skel*_test.go internal/state/dcgm*_test.go internal/state/*test.go controllers/*test.go docs README.md CONTRIBUTING.md 2>/dev/null | head -500

Repository: NVIDIA/gpu-operator

Length of output: 50375


🏁 Script executed:

set -e
printf '%s\n' '--- internal state manager ---'
cat -n internal/state/manager.go
printf '%s\n' '--- direct state manager invocations ---'
rg -n -C 10 'SyncState\(|\.Sync\(ctx|Sync\(.*GPUCluster|SyncState\(' controllers internal --glob '*.go' | head -320
printf '%s\n' '--- state factory registration ---'
rg -n -C 12 'NewStates|NewStateDCGM|NewStateDCGMExporter|GPUClusterCRDName|GPUCluster' internal/state/manager.go controllers | head -320

Repository: NVIDIA/gpu-operator

Length of output: 50375


Preserve existing Service selectors during GPUCluster updates. GPUClusterReconciler reaches the DCGM and DCGM exporter states, and createOrUpdateObjs updates existing Services through mergeService. That helper preserves allocated fields but leaves the desired Spec.Selector unchanged. An upgrade can therefore replace a selector for serving legacy pods with app: nvidia-dcgm-dra or app: nvidia-dcgm-exporter-dra before those pods are ready. Kubernetes then removes the legacy pods from the Service endpoints, causing a temporary hostengine or metrics outage.

Copy the current selector in mergeService for existing Services. Keep the DRA selector for newly created Services.

metadata:
labels:
app: nvidia-dcgm-exporter
nvidia.com/gpu-operator.dcgm-exporter: "true"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we look at alternate names for this label key?

The format of nvidia.com/gpu-operator.dcgm-exporter: "true" seems non-standard for labels.

@cdesiniotis cdesiniotis Sep 17, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe app.kubernetes.io/component: nvidia-dcgm-exporter?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that could work. It is a standardised label

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, we can. This label was selected so that we use the same scheme we used before like here:

NVIDIADriverOwnerLabel = "nvidia.com/gpu-operator.driver.owner"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like app.kubernetes.io/component has a specific meaning as per this K8s docs

How about app.kubernetes.io/name instead?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@rahulait I missed to mention this earlier, using a boolean as the label value typed seemed a bit off to me.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] Preserve stable DCGM discovery across ClusterPolicy and GPUCluster deployments

3 participants