Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
3 changes: 2 additions & 1 deletion pkg/pillar/cmd/volumemgr/handlediskmetrics.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,8 @@ func generateAndPublishVolumeMgrStatus(ctx *volumemgrContext) {
}
st := types.VolumeMgrStatus{
Name: agentName,
Initialized: true,
Initialized: ctx.storageReady,
UnmetCondition: ctx.storageUnmet,

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.

I don't see anyone using this field yet, is there a consumer coming in another PR?

@eriknordmark eriknordmark Aug 2, 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.

No consumer — and Initialized right above it has never had one either; nodeagent is the only subscriber and reads just RemainingSpace. The motivation is making the state available to tests and diagnosis: the status lands in /run/volumemgr/VolumeMgrStatus/volumemgr.json, so a test can ssh in and assert on it, and collect-info picks it up in the bundle.

Adding diag as a consumer was easy, so I've done it here — it prints a warning naming the outstanding gate when cluster storage didn't become usable.

Nothing reports this to the controller today: ZInfoClusterNode carries only the node_status enum and pillar doesn't populate it, so that's an eve-api change and a separate PR. Let's discuss what else we'd want in the API in this area. One thing to design around: these two fields are a one-shot startup outcome, not a live condition — volumemgr decides once after its wait and republishes the same value.

RemainingSpace: remaining,
}
ctx.pubVolumeMgrStatus.Publish(st.Key(), st)
Expand Down
16 changes: 16 additions & 0 deletions pkg/pillar/cmd/volumemgr/volumemgr.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,13 @@ type volumemgrContext struct {
gcRunning bool
initGced bool // Will be marked true after initObjects are garbage collected

// storageReady is false only on an EVE-k node whose cluster storage never
// came up; storageUnmet then carries the outstanding gate. Both are
// reported through VolumeMgrStatus. Written once during startup, before
// the disk-metrics task that reads them is launched.
storageReady bool
storageUnmet string

globalConfig *types.ConfigItemValueMap
GCInitialized bool
vdiskGCTime uint32 // In seconds; XXX delete when OldVolumeStatus is deleted
Expand Down Expand Up @@ -169,6 +176,9 @@ func Run(ps *pubsub.PubSub, loggerArg *logrus.Logger, logArg *base.LogObject, ar
globalConfig: types.DefaultConfigItemValueMap(),
persistType: persist.ReadPersistType(),
hvTypeKube: base.IsHVTypeKube(),
// Only an EVE-k node has cluster storage to wait for; everywhere else
// storage is usable as soon as volumemgr is up.
storageReady: !base.IsHVTypeKube(),
}
agentbase.Init(&ctx, logger, log, agentName,
agentbase.WithPidFile(),
Expand Down Expand Up @@ -341,6 +351,7 @@ func Run(ps *pubsub.PubSub, loggerArg *logrus.Logger, logArg *base.LogObject, ar
})
if err != nil {
log.Errorf("volumemgr run: wait for kubernetes error %v", err)
ctx.storageUnmet = err.Error()
} else {
log.Noticef("volumemgr run: kubernetes node ready, longhorn ready")
}
Expand Down Expand Up @@ -371,9 +382,14 @@ func Run(ps *pubsub.PubSub, loggerArg *logrus.Logger, logArg *base.LogObject, ar
}
}
storageDeadline.Stop()
ctx.storageReady = storageReady
if storageReady {
ctx.storageUnmet = ""
log.Noticef("volumemgr run: cluster storage (longhorn+CDI) ready")
} else {
if ctx.storageUnmet == "" {
ctx.storageUnmet = "cluster storage (longhorn+CDI) not ready"
}
log.Warnf("volumemgr run: timeout waiting for cluster storage; " +
"volumes will defer and retry")
}
Expand Down
10 changes: 9 additions & 1 deletion pkg/pillar/kubeapi/kubeapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -288,14 +288,22 @@ func checkLonghornReady(client kubernetes.Interface, nodeName string) error {
"longhorn-csi-plugin": false,
"engine-image": false,
}
// Check if each daemonset is running and ready on this node
// Check if each daemonset is running and ready on this node. Only the
// expected daemonsets above gate readiness: anything else sharing the
// namespace is not ours to judge, and a permanently unready stray would
// otherwise block every volume on this node for as long as it exists.
for _, lhDaemonset := range lhDaemonsets.Items {
lhDsName := lhDaemonset.GetName()
expected := false
for dsPrefix := range lhExpectedDaemonsets {
if strings.HasPrefix(lhDsName, dsPrefix) {
lhExpectedDaemonsets[dsPrefix] = true
expected = true
}
}
if !expected {
continue
}

var labelSelectors []string
for dsLabelK, dsLabelV := range lhDaemonset.Spec.Template.Labels {
Expand Down
102 changes: 102 additions & 0 deletions pkg/pillar/kubeapi/longhornready_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// Copyright (c) 2026 Zededa, Inc.
// SPDX-License-Identifier: Apache-2.0

//go:build k

package kubeapi

import (
"testing"

appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/client-go/kubernetes/fake"
)

const lhTestNode = "test-node"

func lhDaemonset(name string) *appsv1.DaemonSet {
return &appsv1.DaemonSet{
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "longhorn-system"},
Spec: appsv1.DaemonSetSpec{
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"app": name}},
},
},
}
}

func lhPod(dsName string, ready bool) *corev1.Pod {
phase := corev1.PodRunning
if !ready {
phase = corev1.PodPending
}
return &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: dsName + "-pod",
Namespace: "longhorn-system",
Labels: map[string]string{"app": dsName},
},
Spec: corev1.PodSpec{NodeName: lhTestNode},
Status: corev1.PodStatus{
Phase: phase,
ContainerStatuses: []corev1.ContainerStatus{{Ready: ready}},
},
}
}

// healthyLonghornObjects returns the three daemonsets checkLonghornReady
// expects, each with a Running-and-Ready pod on lhTestNode.
func healthyLonghornObjects() []runtime.Object {
names := []string{"longhorn-manager", "longhorn-csi-plugin", "engine-image-ei-abcdef12"}
objs := make([]runtime.Object, 0, len(names)*2)
for _, n := range names {
objs = append(objs, lhDaemonset(n), lhPod(n, true))
}
return objs
}

func TestCheckLonghornReadyHealthy(t *testing.T) {
client := fake.NewSimpleClientset(healthyLonghornObjects()...)
if err := checkLonghornReady(client, lhTestNode); err != nil {
t.Fatalf("expected ready, got %v", err)
}
}

// A daemonset that is not one of longhorn's own must not gate storage. EVE's
// collect-info leaves a SupportBundle agent daemonset behind in this namespace
// which never becomes ready, and it used to block every volume on the node.
func TestCheckLonghornReadyIgnoresStrayDaemonset(t *testing.T) {
objs := healthyLonghornObjects()
objs = append(objs, lhDaemonset("longhorn-support-bundle-agent"))
client := fake.NewSimpleClientset(objs...)
if err := checkLonghornReady(client, lhTestNode); err != nil {
t.Fatalf("stray daemonset must not gate readiness, got %v", err)
}
}

func TestCheckLonghornReadyMissingExpectedDaemonset(t *testing.T) {
objs := []runtime.Object{
lhDaemonset("longhorn-manager"), lhPod("longhorn-manager", true),
lhDaemonset("longhorn-csi-plugin"), lhPod("longhorn-csi-plugin", true),
}
client := fake.NewSimpleClientset(objs...)
err := checkLonghornReady(client, lhTestNode)
if err == nil {
t.Fatal("expected an error when engine-image is absent")
}
}

func TestCheckLonghornReadyExpectedDaemonsetNotReady(t *testing.T) {
names := []string{"longhorn-manager", "longhorn-csi-plugin", "engine-image-ei-abcdef12"}
var objs []runtime.Object
for _, n := range names {
objs = append(objs, lhDaemonset(n), lhPod(n, n != "longhorn-manager"))
}
client := fake.NewSimpleClientset(objs...)
if err := checkLonghornReady(client, lhTestNode); err == nil {
t.Fatal("expected an error when an expected daemonset pod is not ready")
}
}
10 changes: 8 additions & 2 deletions pkg/pillar/types/volumetypes.go
Original file line number Diff line number Diff line change
Expand Up @@ -619,8 +619,14 @@ func (status VolumeCreatePending) LogDelete(logBase *base.LogObject) {

// VolumeMgrStatus :
type VolumeMgrStatus struct {
Name string
Initialized bool
Name string
Initialized bool
// UnmetCondition names the readiness gate still outstanding when
// Initialized is false, e.g. "longhorn not ready: longhorn missing
// daemonset:engine-image". Empty when Initialized is true. Informational:
// it exists so an operator can tell a converging cluster from a stuck one
// without correlating agent logs.
UnmetCondition string
RemainingSpace uint64 // In bytes. Takes into account "reserved" for dom0
}

Expand Down
Loading