Skip to content

Commit ba6cc85

Browse files
100-JMclaude
andcommitted
Complete the DCGM Exporter ServiceAccount support on the GPUCluster path
Review feedback: the first commit wired the field on the ClusterPolicy path only, leaving the DRA path able to render the reference without honouring the contract behind it. - pass the field through deployments/gpu-operator/templates/gpucluster.yaml, so a Helm GPUCluster install can set it; - add a preSync hook to configurableState and use it for the exporter, so create: false reports NotReady when the ServiceAccount is missing rather than leaving the DaemonSet pending on an object the manifests deliberately omit; - refuse to take over a ServiceAccount that already exists under a configured name and is not owned by the CR. createOrUpdateObjs() would otherwise adopt it on the DRA path and hand it to garbage collection with the GPUCluster. The default name stays tolerant so an upgrade that lost the owner reference keeps converging; - reclaim the operator-owned default ServiceAccount once a different name takes over, on both paths. Renaming between two custom names is not tracked, so only the default is reclaimed; - cover the GPUCluster path: the rendered ServiceAccount, DaemonSet and RBAC subjects for a custom name, the omitted ServiceAccount for create: false, and each preSync branch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UiFVTVAyEMmn68Xvo9LSTf Signed-off-by: 백지명 <wlaud9805@gmail.com>
1 parent dd3685a commit ba6cc85

6 files changed

Lines changed: 370 additions & 5 deletions

File tree

‎controllers/object_controls.go‎

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import (
2828
"strconv"
2929
"strings"
3030

31+
"github.com/go-logr/logr"
3132
apiconfigv1 "github.com/openshift/api/config/v1"
3233
apiimagev1 "github.com/openshift/api/image/v1"
3334
secv1 "github.com/openshift/api/security/v1"
@@ -350,6 +351,28 @@ func (n ClusterPolicyController) isServiceAccountOwned(ctx context.Context, obj
350351
return metav1.IsControlledBy(found, n.singleton), nil
351352
}
352353

354+
// deleteOwnedServiceAccount removes a ServiceAccount left behind by a previous
355+
// configuration, but only when this ClusterPolicy owns it: an object the user
356+
// provisioned under the same name is left alone.
357+
func (n ClusterPolicyController) deleteOwnedServiceAccount(ctx context.Context, name string, logger logr.Logger) error {
358+
found := &corev1.ServiceAccount{}
359+
err := n.client.Get(ctx, types.NamespacedName{Namespace: n.operatorNamespace, Name: name}, found)
360+
if err != nil {
361+
if apierrors.IsNotFound(err) {
362+
return nil
363+
}
364+
return err
365+
}
366+
if !metav1.IsControlledBy(found, n.singleton) {
367+
return nil
368+
}
369+
logger.V(1).Info("Removing the superseded dcgm-exporter ServiceAccount", "Name", name)
370+
if err := n.client.Delete(ctx, found); err != nil && !apierrors.IsNotFound(err) {
371+
return err
372+
}
373+
return nil
374+
}
375+
353376
func ServiceAccount(n ClusterPolicyController) (gpuv1.State, error) {
354377
ctx := n.ctx
355378
state := n.idx
@@ -406,18 +429,41 @@ func ServiceAccount(n ClusterPolicyController) (gpuv1.State, error) {
406429
return gpuv1.Ready, nil
407430
}
408431

432+
isRenamed := isDCGMExporter && obj.Name != DCGMExporterDefaultServiceAccountName
433+
if isRenamed {
434+
// Only a name the user chose can collide with an unrelated object; the default is
435+
// left tolerant so an upgrade that lost the owner reference keeps converging.
436+
owned, err := n.isServiceAccountOwned(ctx, obj)
437+
if err != nil {
438+
return gpuv1.NotReady, err
439+
}
440+
if !owned {
441+
err := fmt.Errorf("ServiceAccount %q already exists in namespace %q and is not managed by this ClusterPolicy; "+
442+
"set dcgmExporter.serviceAccount.create to false to reference it", obj.Name, obj.Namespace)
443+
logger.Error(err, "Refusing to take over an existing ServiceAccount")
444+
return gpuv1.NotReady, err
445+
}
446+
}
447+
409448
if err := controllerutil.SetControllerReference(n.singleton, obj, n.scheme); err != nil {
410449
return gpuv1.NotReady, err
411450
}
412451

413452
if err := n.client.Create(ctx, obj); err != nil {
414-
if apierrors.IsAlreadyExists(err) {
415-
logger.Info("Found Resource, skipping update")
416-
return gpuv1.Ready, nil
453+
if !apierrors.IsAlreadyExists(err) {
454+
logger.Info("Couldn't create", "Error", err)
455+
return gpuv1.NotReady, err
417456
}
457+
logger.Info("Found Resource, skipping update")
458+
}
418459

419-
logger.Info("Couldn't create", "Error", err)
420-
return gpuv1.NotReady, err
460+
if isRenamed {
461+
// A previous configuration may have left the operator-owned default behind.
462+
// Renaming between two custom names is not tracked, so only the default is
463+
// reclaimed here.
464+
if err := n.deleteOwnedServiceAccount(ctx, DCGMExporterDefaultServiceAccountName, logger); err != nil {
465+
return gpuv1.NotReady, err
466+
}
421467
}
422468
return gpuv1.Ready, nil
423469
}

‎controllers/object_controls_test.go‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2683,6 +2683,56 @@ func TestDCGMExporterServiceAccountReconcile(t *testing.T) {
26832683
require.True(t, ok, "only a ServiceAccount owned by the ClusterPolicy may be deleted")
26842684
})
26852685

2686+
t.Run("a configured name refuses to take over an unowned ServiceAccount", func(t *testing.T) {
2687+
k8s := fake.NewClientBuilder().WithScheme(testScheme).
2688+
WithObjects(serviceAccount("metrics-identity")).Build()
2689+
cp := clusterPolicy()
2690+
cp.Spec.DCGMExporter.ServiceAccount = &gpuv1.DCGMExporterServiceAccountConfig{Name: "metrics-identity"}
2691+
n := newController(k8s, cp)
2692+
2693+
state, err := ServiceAccount(n)
2694+
require.Error(t, err)
2695+
require.Equal(t, gpuv1.NotReady, state)
2696+
2697+
sa, ok := getServiceAccount(t, k8s, "metrics-identity")
2698+
require.True(t, ok)
2699+
require.Empty(t, sa.OwnerReferences, "an existing ServiceAccount must not be adopted")
2700+
})
2701+
2702+
t.Run("renaming reclaims the superseded operator-owned default", func(t *testing.T) {
2703+
cp := clusterPolicy()
2704+
previous := serviceAccount(DCGMExporterDefaultServiceAccountName)
2705+
require.NoError(t, controllerutil.SetControllerReference(cp, previous, testScheme))
2706+
2707+
k8s := fake.NewClientBuilder().WithScheme(testScheme).WithObjects(previous).Build()
2708+
cp.Spec.DCGMExporter.ServiceAccount = &gpuv1.DCGMExporterServiceAccountConfig{Name: "metrics-identity"}
2709+
n := newController(k8s, cp)
2710+
2711+
state, err := ServiceAccount(n)
2712+
require.NoError(t, err)
2713+
require.Equal(t, gpuv1.Ready, state)
2714+
2715+
_, ok := getServiceAccount(t, k8s, "metrics-identity")
2716+
require.True(t, ok)
2717+
_, ok = getServiceAccount(t, k8s, DCGMExporterDefaultServiceAccountName)
2718+
require.False(t, ok, "the superseded default must be removed")
2719+
})
2720+
2721+
t.Run("renaming keeps a previous ServiceAccount the operator does not own", func(t *testing.T) {
2722+
k8s := fake.NewClientBuilder().WithScheme(testScheme).
2723+
WithObjects(serviceAccount(DCGMExporterDefaultServiceAccountName)).Build()
2724+
cp := clusterPolicy()
2725+
cp.Spec.DCGMExporter.ServiceAccount = &gpuv1.DCGMExporterServiceAccountConfig{Name: "metrics-identity"}
2726+
n := newController(k8s, cp)
2727+
2728+
state, err := ServiceAccount(n)
2729+
require.NoError(t, err)
2730+
require.Equal(t, gpuv1.Ready, state)
2731+
2732+
_, ok := getServiceAccount(t, k8s, DCGMExporterDefaultServiceAccountName)
2733+
require.True(t, ok, "only an owned ServiceAccount may be reclaimed")
2734+
})
2735+
26862736
t.Run("a non-DCGM state deletes its ServiceAccount regardless of ownership", func(t *testing.T) {
26872737
// The ownership check is scoped to the DCGM Exporter; every other state keeps
26882738
// the previous unconditional cleanup on disable.

‎deployments/gpu-operator/templates/gpucluster.yaml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,9 @@ spec:
124124
{{- if .Values.dcgmExporter.serviceMonitor }}
125125
serviceMonitor: {{ toYaml .Values.dcgmExporter.serviceMonitor | nindent 6 }}
126126
{{- end }}
127+
{{- if .Values.dcgmExporter.serviceAccount }}
128+
serviceAccount: {{ toYaml .Values.dcgmExporter.serviceAccount | nindent 6 }}
129+
{{- end }}
127130
{{- if and (.Values.dcgmExporter.config) (.Values.dcgmExporter.config.name) }}
128131
config:
129132
name: {{ .Values.dcgmExporter.config.name }}

‎internal/state/configurable_state.go‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,12 @@ type configurableState struct {
4949
// image path and DRA apiVersion. It receives ctx and the skeleton so operands that
5050
// need the client or logging (e.g. dcgm-exporter's ServiceMonitor CRD probe) can use them.
5151
buildRenderData func(ctx context.Context, s *configurableState, cr *nvidiav1alpha1.GPUCluster, imagePath, apiVersion, openshiftVersion string) (any, error)
52+
53+
// preSync runs after the manifests render but before they are applied, for operands
54+
// that depend on cluster state the templates cannot express. Returning an error marks
55+
// the state NotReady, so it is the place to surface a misconfiguration instead of
56+
// applying objects that cannot converge.
57+
preSync func(ctx context.Context, s *configurableState, cr *nvidiav1alpha1.GPUCluster) error
5258
}
5359

5460
var _ State = (*configurableState)(nil)
@@ -68,6 +74,12 @@ func (s *configurableState) Sync(ctx context.Context, customResource any, infoCa
6874
return s.handleStateObjectsDeletion(ctx)
6975
}
7076

77+
if s.preSync != nil {
78+
if err := s.preSync(ctx, s, cr); err != nil {
79+
return SyncStateNotReady, err
80+
}
81+
}
82+
7183
return s.syncObjects(ctx, cr, objs)
7284
}
7385

‎internal/state/dcgm_exporter.go‎

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,16 @@ package state
1818

1919
import (
2020
"context"
21+
"fmt"
2122
"path/filepath"
2223
"strings"
2324

25+
corev1 "k8s.io/api/core/v1"
26+
apierrors "k8s.io/apimachinery/pkg/api/errors"
27+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2428
"k8s.io/apimachinery/pkg/runtime"
2529
"k8s.io/apimachinery/pkg/runtime/schema"
30+
"k8s.io/apimachinery/pkg/types"
2631
"sigs.k8s.io/controller-runtime/pkg/client"
2732
"sigs.k8s.io/controller-runtime/pkg/log"
2833

@@ -71,6 +76,7 @@ func NewStateDCGMExporter(
7176
},
7277
imageEnvName: dcgmExporterImageEnvName,
7378
buildRenderData: buildDCGMExporterRenderData,
79+
preSync: checkDCGMExporterServiceAccount,
7480
}, nil
7581
}
7682

@@ -149,6 +155,78 @@ func buildDCGMExporterRenderData(ctx context.Context, s *configurableState, cr *
149155
}, nil
150156
}
151157

158+
// checkDCGMExporterServiceAccount reconciles the parts of the ServiceAccount contract the
159+
// manifests cannot express: a ServiceAccount the user brings has to already exist, one the
160+
// operator would manage must not be an existing object owned by somebody else, and the
161+
// operator-owned default is removed once a different name takes over.
162+
func checkDCGMExporterServiceAccount(ctx context.Context, s *configurableState, cr *nvidiav1alpha1.GPUCluster) error {
163+
spec := cr.Spec.DCGMExporter
164+
name := spec.GetServiceAccountName(dcgmExporterDefaultServiceAccountName)
165+
166+
if !spec.IsServiceAccountCreateEnabled() {
167+
// The manifests omit the ServiceAccount entirely, so a missing one would leave
168+
// the DaemonSet pending without any signal.
169+
if _, err := s.getServiceAccount(ctx, name); err != nil {
170+
if apierrors.IsNotFound(err) {
171+
return fmt.Errorf(
172+
"ServiceAccount %q configured with create=false does not exist in namespace %q",
173+
name, s.namespace)
174+
}
175+
return err
176+
}
177+
return nil
178+
}
179+
180+
if name == dcgmExporterDefaultServiceAccountName {
181+
return nil
182+
}
183+
184+
// Adopting an object the operator did not create would hand it to garbage collection
185+
// on CR deletion, so a name that is already taken has to be opted into explicitly.
186+
existing, err := s.getServiceAccount(ctx, name)
187+
if err != nil && !apierrors.IsNotFound(err) {
188+
return err
189+
}
190+
if err == nil && !metav1.IsControlledBy(existing, cr) {
191+
return fmt.Errorf(
192+
"ServiceAccount %q already exists in namespace %q and is not managed by this GPUCluster; "+
193+
"set dcgmExporter.serviceAccount.create to false to reference it",
194+
name, s.namespace)
195+
}
196+
197+
return s.deleteOwnedServiceAccount(ctx, cr, dcgmExporterDefaultServiceAccountName)
198+
}
199+
200+
// getServiceAccount reads a ServiceAccount from the operand namespace.
201+
func (s *configurableState) getServiceAccount(ctx context.Context, name string) (*corev1.ServiceAccount, error) {
202+
sa := &corev1.ServiceAccount{}
203+
err := s.client.Get(ctx, types.NamespacedName{Namespace: s.namespace, Name: name}, sa)
204+
return sa, err
205+
}
206+
207+
// deleteOwnedServiceAccount removes a ServiceAccount left behind by a previous
208+
// configuration, but only when this CR owns it: an object the user provisioned under the
209+
// same name is left alone. Renaming from one custom name to another is not tracked, so
210+
// only the operator default is reclaimed here.
211+
func (s *configurableState) deleteOwnedServiceAccount(ctx context.Context, cr *nvidiav1alpha1.GPUCluster, name string) error {
212+
sa, err := s.getServiceAccount(ctx, name)
213+
if err != nil {
214+
if apierrors.IsNotFound(err) {
215+
return nil
216+
}
217+
return err
218+
}
219+
if !metav1.IsControlledBy(sa, cr) {
220+
return nil
221+
}
222+
log.FromContext(ctx).V(consts.LogLevelInfo).Info(
223+
"Removing the superseded dcgm-exporter ServiceAccount", "Name", name)
224+
if err := s.client.Delete(ctx, sa); err != nil && !apierrors.IsNotFound(err) {
225+
return err
226+
}
227+
return nil
228+
}
229+
152230
// serviceMonitorCRDServed reports whether the cluster serves the monitoring.coreos.com
153231
// ServiceMonitor kind (i.e. the Prometheus Operator CRDs are installed).
154232
func serviceMonitorCRDServed(k8sClient client.Client) bool {

0 commit comments

Comments
 (0)