Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions cmd/mps-control-daemon/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,12 @@ func startDaemons(c *cli.Context, cfg *Config) ([]*mps.Daemon, bool, error) {
klog.Info("No devices are configured for MPS sharing; Waiting indefinitely.")
}

// Clear any stale .ready before starting: /mps is a hostPath, so a marker
// from a previous run can survive a restart and signal readiness too early.
if err := os.Remove(mps.ContainerRoot.ReadyFilePath()); err != nil && !os.IsNotExist(err) {
return mpsDaemons, true, fmt.Errorf("failed to remove stale .ready file: %w", err)
}

// Loop through all MPS daemons and start them.
// If any daemon fails to start, all daemons are started again.
for _, mpsDaemon := range mpsDaemons {
Expand All @@ -213,7 +219,7 @@ func startDaemons(c *cli.Context, cfg *Config) ([]*mps.Daemon, bool, error) {
return mpsDaemons, true, nil
}
}
readyFile, err := os.Create("/mps/.ready")
readyFile, err := os.Create(mps.ContainerRoot.ReadyFilePath())

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 remove .ready before starting the mps daemons? /mps is a hostPath, so the file can survive a container restart. A stale .ready can make the device plugin think mps is ready before the new daemon has finished applying the new config.

if err != nil {
return mpsDaemons, true, fmt.Errorf("failed to create .ready file")
}
Expand All @@ -223,7 +229,7 @@ func startDaemons(c *cli.Context, cfg *Config) ([]*mps.Daemon, bool, error) {
}

func stopDaemons(mpsDaemons ...*mps.Daemon) error {
if err := os.Remove("/mps/.ready"); err != nil {
if err := os.Remove(mps.ContainerRoot.ReadyFilePath()); err != nil {
klog.Warningf("Failed to remove .ready file: %v", err)
}
klog.Info("Stopping MPS daemons.")
Expand Down
13 changes: 13 additions & 0 deletions cmd/mps-control-daemon/mps/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,19 @@ func (d *Daemon) AssertHealthy() error {
return err
}

// Ready reports whether the .ready file exists, i.e. the MPS daemons have
// finished initialization. A stat error other than not-exist is returned.
func (d *Daemon) Ready() (bool, error) {
_, err := os.Stat(d.root.ReadyFilePath())
if os.IsNotExist(err) {
return false, nil
}
if err != nil {
return false, err
}
return true, nil
}

// EchoPipeToControl sends the specified command to the MPS control daemon.
func (d *Daemon) EchoPipeToControl(command string) (string, error) {
var out bytes.Buffer
Expand Down
62 changes: 62 additions & 0 deletions cmd/mps-control-daemon/mps/daemon_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/**
# Copyright (c) NVIDIA CORPORATION. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
**/

package mps

import (
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/require"
)

func TestReadyFilePath(t *testing.T) {
require.Equal(t, "/mps/.ready", ContainerRoot.ReadyFilePath())
require.Equal(t, "/custom/root/.ready", Root("/custom/root").ReadyFilePath())
}

func TestDaemonReady(t *testing.T) {
root := t.TempDir()
d := &Daemon{root: Root(root)}

ready, err := d.Ready()
require.NoError(t, err)
require.False(t, ready, "not ready before the .ready file exists")

require.NoError(t, os.WriteFile(filepath.Join(root, ".ready"), nil, 0o644))
ready, err = d.Ready()
require.NoError(t, err)
require.True(t, ready, "ready once the .ready file exists")

require.NoError(t, os.Remove(filepath.Join(root, ".ready")))
ready, err = d.Ready()
require.NoError(t, err)
require.False(t, ready, "not ready after the .ready file is removed")
}

// TestDaemonReadyStatError verifies a stat error other than not-exist is
// surfaced rather than reported as "not ready". The root is a regular file, so
// stat-ing a path beneath it fails with ENOTDIR.
func TestDaemonReadyStatError(t *testing.T) {
f := filepath.Join(t.TempDir(), "not-a-dir")
require.NoError(t, os.WriteFile(f, nil, 0o644))
d := &Daemon{root: Root(f)}

ready, err := d.Ready()
require.Error(t, err)
require.False(t, ready)
}
6 changes: 6 additions & 0 deletions cmd/mps-control-daemon/mps/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ func (r Root) startedFile(resourceName spec.ResourceName) string {
return r.Path(string(resourceName), ".started")
}

// ReadyFilePath returns the node-global .ready file, created only after all MPS
// daemons finish initialization.
func (r Root) ReadyFilePath() string {
return r.Path(".ready")
}

// Path returns a path relative to the MPS root.
func (r Root) Path(parts ...string) string {
pathparts := append([]string{string(r)}, parts...)
Expand Down
2 changes: 1 addition & 1 deletion cmd/nvidia-device-plugin/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -406,7 +406,7 @@ func startPlugins(c *cli.Context, o *options) ([]plugin.Interface, bool, error)
}

// Start the gRPC server for plugin p and connect it with the kubelet.
if err := p.Start(o.kubeletSocket); err != nil {
if err := p.Start(c.Context, o.kubeletSocket); err != nil {
klog.Errorf("Failed to start plugin: %v", err)
return plugins, true, nil
}
Expand Down
8 changes: 6 additions & 2 deletions internal/plugin/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,15 @@

package plugin

import "github.com/NVIDIA/k8s-device-plugin/internal/rm"
import (
"context"

"github.com/NVIDIA/k8s-device-plugin/internal/rm"
)

// Interface defines the API for the plugin package
type Interface interface {
Devices() rm.Devices
Start(string) error
Start(context.Context, string) error
Stop() error
}
38 changes: 33 additions & 5 deletions internal/plugin/mps.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,12 @@
package plugin

import (
"context"
"errors"
"fmt"
"time"

"k8s.io/apimachinery/pkg/util/wait"
"k8s.io/klog/v2"
pluginapi "k8s.io/kubelet/pkg/apis/deviceplugin/v1beta1"

Expand All @@ -28,6 +31,13 @@ import (
"github.com/NVIDIA/k8s-device-plugin/internal/rm"
)

const (
mpsReadyCheckInterval = 5 * time.Second
// mpsReadyCheckTimeout bounds a single wait attempt; on timeout the plugin
// manager retries.
mpsReadyCheckTimeout = 5 * time.Minute
)

type mpsOptions struct {
enabled bool
resourceName spec.ResourceName
Expand Down Expand Up @@ -58,16 +68,34 @@ func (o *options) getMPSOptions(resourceManager rm.ResourceManager) (mpsOptions,
return m, nil
}

func (m *mpsOptions) waitForDaemon() error {
func (m *mpsOptions) waitForDaemon(ctx context.Context) error {
if m == nil || !m.enabled {
return nil
}
// TODO: Check the .ready file here.
// TODO: Have some retry strategy here.

return wait.PollUntilContextTimeout(ctx, mpsReadyCheckInterval, mpsReadyCheckTimeout, true, func(context.Context) (bool, error) {
if err := m.checkDaemonReady(); err != nil {
klog.InfoS("Waiting for MPS daemon to be ready", "resource", m.resourceName, "reason", err)
return false, nil
}
klog.InfoS("MPS daemon is ready", "resource", m.resourceName)
return true, nil
})
}

// checkDaemonReady requires the .ready file (written after full configuration)
// and a responsive pipe; AssertHealthy alone responds before config is applied.
func (m *mpsOptions) checkDaemonReady() error {
ready, err := m.daemon.Ready()

@abrarshivani abrarshivani Sep 22, 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.

One case I think is still possible: say the current config is A and it changes to B. The device plugin can observe B before the MPS daemon does. In that window, .ready may still be from A and the MPS pipe may still be healthy, so this check can succeed before MPS has applied B.

This should converge once the MPS daemon processes the update, so I don't think it needs to block this PR, but ideally readiness should also confirm that MPS is running the expected config

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good catch, you're right this gates startup but not a config change. Opened #2055 to track it; I'll follow up there once this PR merges.
Thanks!

if err != nil {
return fmt.Errorf("checking MPS daemon readiness: %w", err)
}
if !ready {
return fmt.Errorf("MPS daemon has not signalled readiness")
}
if err := m.daemon.AssertHealthy(); err != nil {
return fmt.Errorf("error checking MPS daemon health: %w", err)
return fmt.Errorf("MPS daemon is not healthy: %w", err)
}
klog.InfoS("MPS daemon is healthy", "resource", m.resourceName)
return nil
}

Expand Down
41 changes: 41 additions & 0 deletions internal/plugin/mps_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
# Copyright (c) NVIDIA CORPORATION. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
**/

package plugin

import (
"testing"

"github.com/stretchr/testify/require"

"github.com/NVIDIA/k8s-device-plugin/cmd/mps-control-daemon/mps"
)

// TestCheckDaemonReadyRequiresReadyFile is the regression for this PR's race:
// even when the control pipe would be healthy, readiness must be withheld until
// the .ready file exists. With no .ready file, checkDaemonReady returns
// not-ready before ever consulting the pipe (AssertHealthy).
func TestCheckDaemonReadyRequiresReadyFile(t *testing.T) {
root := t.TempDir() // no .ready file
m := &mpsOptions{
enabled: true,
daemon: mps.NewDaemon(nil, mps.Root(root)),
}

err := m.checkDaemonReady()
require.Error(t, err)
require.Contains(t, err.Error(), "has not signalled readiness")
}
4 changes: 2 additions & 2 deletions internal/plugin/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,10 +126,10 @@ func (plugin *nvidiaDevicePlugin) Devices() rm.Devices {

// Start starts the gRPC server, registers the device plugin with the Kubelet,
// and starts the device healthchecks.
func (plugin *nvidiaDevicePlugin) Start(kubeletSocket string) error {
func (plugin *nvidiaDevicePlugin) Start(ctx context.Context, kubeletSocket string) error {
plugin.initialize()

if err := plugin.mps.waitForDaemon(); err != nil {
if err := plugin.mps.waitForDaemon(ctx); err != nil {
return fmt.Errorf("error waiting for MPS daemon: %w", err)
}

Expand Down