From 9869293cdec0967ac7498116295048fdfeced4b0 Mon Sep 17 00:00:00 2001 From: Krystian Bednarczuk Date: Tue, 27 Jan 2026 13:23:32 +0100 Subject: [PATCH 1/2] REP-1722: Expose allocatable storage per node --- .../daemon/pipeline/storage_info_provider.go | 195 +++++- .../pipeline/storage_info_provider_test.go | 563 ++++++++++++++++++ 2 files changed, 751 insertions(+), 7 deletions(-) diff --git a/cmd/agent/daemon/pipeline/storage_info_provider.go b/cmd/agent/daemon/pipeline/storage_info_provider.go index d62e3553..93e65248 100644 --- a/cmd/agent/daemon/pipeline/storage_info_provider.go +++ b/cmd/agent/daemon/pipeline/storage_info_provider.go @@ -3,10 +3,12 @@ package pipeline import ( "bufio" "context" + "encoding/json" "fmt" "math" "os" "path/filepath" + "regexp" "strconv" "strings" "syscall" @@ -18,6 +20,8 @@ import ( "golang.org/x/sys/unix" "google.golang.org/grpc" "google.golang.org/grpc/encoding/gzip" + "gopkg.in/yaml.v3" + "k8s.io/apimachinery/pkg/api/resource" kubepb "github.com/castai/kvisor/api/v1/kube" "github.com/castai/kvisor/pkg/logging" @@ -82,13 +86,15 @@ type FilesystemMetric struct { // NodeStatsSummaryMetric represents node-level filesystem statistics from kubelet type NodeStatsSummaryMetric struct { - NodeName string `avro:"node_name"` - NodeTemplate *string `avro:"node_template"` - ImageFsSizeBytes *int64 `avro:"image_fs_size_bytes"` - ImageFsUsedBytes *int64 `avro:"image_fs_used_bytes"` - ContainerFsSizeBytes *int64 `avro:"container_fs_size_bytes"` - ContainerFsUsedBytes *int64 `avro:"container_fs_used_bytes"` - Timestamp time.Time `avro:"ts"` + NodeName string `avro:"node_name"` + NodeTemplate *string `avro:"node_template"` + ImageFsSizeBytes *int64 `avro:"image_fs_size_bytes"` + ImageFsUsedBytes *int64 `avro:"image_fs_used_bytes"` + ContainerFsSizeBytes *int64 `avro:"container_fs_size_bytes"` + ContainerFsUsedBytes *int64 `avro:"container_fs_used_bytes"` + NodeFsCapacityBytes *int64 `avro:"node_fs_capacity_bytes"` + NodeFsAllocatableBytes *int64 `avro:"node_fs_allocatable_bytes"` + Timestamp time.Time `avro:"ts"` } // K8sPodVolumeMetric represents pod volume information from Kubernetes @@ -136,6 +142,14 @@ type SysfsStorageInfoProvider struct { kubeClient kubepb.KubeAPIClient nodeCache *freelru.SyncedLRU[string, *kubepb.Node] wellKnownPathDeviceID map[string]uint64 + kubeletConfig *KubeletConfig +} + +// KubeletConfig represents the relevant parts of the kubelet configuration file +type KubeletConfig struct { + EvictionHard map[string]string `yaml:"evictionHard" json:"evictionHard"` + KubeReserved map[string]string `yaml:"kubeReserved" json:"kubeReserved"` + SystemReserved map[string]string `yaml:"systemReserved" json:"systemReserved"` } const ( @@ -185,6 +199,11 @@ func NewStorageInfoProvider(log *logging.Logger, kubeClient kubepb.KubeAPIClient Warn("failed to stat crio path") } + kubeletConfig, err := readKubeletConfig(hostPathRoot, log) + if err != nil { + log.With("error", err).Warn("failed to read kubelet config, allocatable calculation will use capacity only") + } + return &SysfsStorageInfoProvider{ storageState: &storageMetricsState{ blockDevices: make(map[string]*BlockDeviceMetric), @@ -198,6 +217,7 @@ func NewStorageInfoProvider(log *logging.Logger, kubeClient kubepb.KubeAPIClient kubeClient: kubeClient, nodeCache: nodeCache, wellKnownPathDeviceID: wellKnownPathDeviceID, + kubeletConfig: kubeletConfig, }, nil } @@ -309,6 +329,13 @@ func (s *SysfsStorageInfoProvider) CollectNodeStatsSummary(ctx context.Context) } } + nodefsCapacity := s.getNodefsCapacity() + if nodefsCapacity > 0 { + allocatable := s.calculateAllocatableBytes(nodefsCapacity) + metric.NodeFsCapacityBytes = lo.ToPtr(nodefsCapacity) + metric.NodeFsAllocatableBytes = lo.ToPtr(allocatable) + } + return metric, nil } @@ -1061,3 +1088,157 @@ func buildFilesystemLabels(log *logging.Logger, fsMountPointDeviceID uint64, wel return labels } + +var kubeletConfigPaths = []string{ + "/var/lib/kubelet/config.yaml", // kubeadm, AKS + "/home/kubernetes/kubelet-config.yaml", // GKE + "/etc/kubernetes/kubelet/kubelet-config.json", // EKS +} + +func readKubeletConfig(hostRootPath string, log *logging.Logger) (*KubeletConfig, error) { + for _, configPath := range kubeletConfigPaths { + fullPath := filepath.Join(hostRootPath, configPath) + data, err := os.ReadFile(fullPath) + if err != nil { + if !os.IsNotExist(err) { + log.With("path", fullPath).With("error", err).Debug("failed to read kubelet config") + } + continue + } + + var config KubeletConfig + if strings.HasSuffix(configPath, ".json") { + err = json.Unmarshal(data, &config) + } else { + err = yaml.Unmarshal(data, &config) + } + + if err != nil { + log.With("path", fullPath).With("error", err).Debug("failed to parse kubelet config") + continue + } + + log.With("path", fullPath).Debug("loaded kubelet config") + return &config, nil + } + + return nil, fmt.Errorf("kubelet config not found in any of: %v", kubeletConfigPaths) +} + +var percentageRegex = regexp.MustCompile(`^(\d+(?:\.\d+)?)%$`) + +// parseEvictionThreshold parses a Kubernetes eviction threshold value. +// It handles both percentage values (e.g., "10%") and absolute quantities (e.g., "1Gi", "500Mi"). +func parseEvictionThreshold(threshold string, capacityBytes int64) (int64, error) { + if threshold == "" { + return 0, nil + } + + if matches := percentageRegex.FindStringSubmatch(threshold); len(matches) == 2 { + percentage, err := strconv.ParseFloat(matches[1], 64) + if err != nil { + return 0, fmt.Errorf("parsing percentage %q: %w", matches[1], err) + } + return int64(float64(capacityBytes) * percentage / 100.0), nil + } + + return parseQuantity(threshold) +} + +// parseQuantity parses a Kubernetes quantity string (e.g., "1Gi", "500Mi", "1G", "500M", "1000") +func parseQuantity(s string) (int64, error) { + if s == "" { + return 0, nil + } + + quantity, err := resource.ParseQuantity(s) + if err != nil { + return 0, err + } + + return quantity.Value(), nil +} + +// calculateAllocatableBytes calculates the allocatable storage bytes using the Kubernetes formula: +// allocatable = capacity - evictionReserve - kubeReserved - systemReserved +func (s *SysfsStorageInfoProvider) calculateAllocatableBytes(capacityBytes int64) int64 { + if s.kubeletConfig == nil { + s.log.With("capacityBytes", capacityBytes).Debug("calculateAllocatableBytes: kubeletConfig is nil, returning capacity") + return capacityBytes + } + + var evictionReserve int64 + if s.kubeletConfig.EvictionHard != nil { + threshold := s.kubeletConfig.EvictionHard["nodefs.available"] + var err error + evictionReserve, err = parseEvictionThreshold(threshold, capacityBytes) + if err != nil { + s.log.With("value", threshold).With("error", err).Warn("failed to parse eviction threshold") + } + } + + var kubeReserved int64 + if s.kubeletConfig.KubeReserved != nil { + value := s.kubeletConfig.KubeReserved["ephemeral-storage"] + var err error + kubeReserved, err = parseQuantity(value) + if err != nil { + s.log.With("value", value).With("error", err).Warn("failed to parse kubeReserved ephemeral-storage") + } + } + + var systemReserved int64 + if s.kubeletConfig.SystemReserved != nil { + value := s.kubeletConfig.SystemReserved["ephemeral-storage"] + var err error + systemReserved, err = parseQuantity(value) + if err != nil { + s.log.With("value", value).With("error", err).Warn("failed to parse systemReserved ephemeral-storage") + } + } + + allocatable := capacityBytes - evictionReserve - kubeReserved - systemReserved + s.log.With("capacityBytes", capacityBytes). + With("evictionReserve", evictionReserve). + With("kubeReserved", kubeReserved). + With("systemReserved", systemReserved). + With("allocatable", allocatable). + Debug("calculateAllocatableBytes: computed allocatable") + if allocatable < 0 { + return 0 + } + return allocatable +} + +// getNodefsCapacity returns the total capacity across all well-known storage paths, +// deduplicating by device ID to avoid counting the same filesystem multiple times. +func (s *SysfsStorageInfoProvider) getNodefsCapacity() int64 { + paths := []string{kubeletPath, containerdPath, castaiStoragePath} + seenDevices := make(map[uint64]bool) + var totalCapacity int64 + + for _, path := range paths { + devID, ok := s.wellKnownPathDeviceID[path] + if !ok { + continue + } + if seenDevices[devID] { + continue + } + seenDevices[devID] = true + + fullPath := filepath.Join(s.hostRootPath, path) + sizeBytes, _, _, _, _, err := getFilesystemStats(fullPath) + if err != nil { + s.log.With("path", fullPath).With("error", err). + Debug("failed to get capacity for path") + continue + } + totalCapacity += sizeBytes + } + + if totalCapacity == 0 { + s.log.Warn("no capacity found for any well-known storage path") + } + return totalCapacity +} diff --git a/cmd/agent/daemon/pipeline/storage_info_provider_test.go b/cmd/agent/daemon/pipeline/storage_info_provider_test.go index 5a267d17..1a34c8b4 100644 --- a/cmd/agent/daemon/pipeline/storage_info_provider_test.go +++ b/cmd/agent/daemon/pipeline/storage_info_provider_test.go @@ -2,6 +2,7 @@ package pipeline import ( "os" + "path/filepath" "strconv" "strings" "testing" @@ -682,3 +683,565 @@ func TestGetDiskType(t *testing.T) { }) } } + +func TestParseQuantity(t *testing.T) { + tests := []struct { + name string + input string + expected int64 + expectErr bool + }{ + {"empty string", "", 0, false}, + {"plain number", "1000", 1000, false}, + + {"kilobytes binary Ki", "1Ki", 1024, false}, + {"megabytes binary Mi", "1Mi", 1024 * 1024, false}, + {"gigabytes binary Gi", "1Gi", 1024 * 1024 * 1024, false}, + {"terabytes binary Ti", "1Ti", 1024 * 1024 * 1024 * 1024, false}, + {"petabytes binary Pi", "1Pi", 1024 * 1024 * 1024 * 1024 * 1024, false}, + + {"kilobytes decimal k", "1k", 1000, false}, + {"megabytes decimal M", "1M", 1000 * 1000, false}, + {"gigabytes decimal G", "1G", 1000 * 1000 * 1000, false}, + {"terabytes decimal T", "1T", 1000 * 1000 * 1000 * 1000, false}, + + {"500Mi", "500Mi", 500 * 1024 * 1024, false}, + {"10Gi", "10Gi", 10 * 1024 * 1024 * 1024, false}, + {"100Gi", "100Gi", 100 * 1024 * 1024 * 1024, false}, + + {"invalid format returns error", "abc", 0, true}, + {"unrecognized suffix returns error", "1Xi", 0, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := parseQuantity(tt.input) + if tt.expectErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestParseEvictionThreshold(t *testing.T) { + capacity := int64(100 * 1024 * 1024 * 1024) // 100Gi + + tests := []struct { + name string + input string + expected int64 + expectErr bool + }{ + {"empty string", "", 0, false}, + {"10 percent", "10%", capacity / 10, false}, + {"15 percent", "15%", int64(float64(capacity) * 0.15), false}, + {"100 percent", "100%", capacity, false}, + {"0 percent", "0%", 0, false}, + {"5.5 percent", "5.5%", int64(float64(capacity) * 0.055), false}, + + {"absolute 1Gi", "1Gi", 1024 * 1024 * 1024, false}, + {"absolute 500Mi", "500Mi", 500 * 1024 * 1024, false}, + {"absolute 100Mi", "100Mi", 100 * 1024 * 1024, false}, + + {"invalid percent - not matching regex falls through to parseQuantity", "abc%", 0, true}, + {"negative percentage - rejected by regex", "-10%", 0, true}, + {"malformed decimal - rejected by regex", "10.5.5%", 0, true}, + {"space before percent - rejected by regex", "10 %", 0, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := parseEvictionThreshold(tt.input, capacity) + if tt.expectErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestCalculateAllocatableBytes(t *testing.T) { + capacity := int64(100 * 1024 * 1024 * 1024) // 100Gi + + tests := []struct { + name string + kubeletConfig *KubeletConfig + expected int64 + }{ + { + name: "nil config returns capacity", + kubeletConfig: nil, + expected: capacity, + }, + { + name: "empty config returns capacity", + kubeletConfig: &KubeletConfig{ + EvictionHard: nil, + KubeReserved: nil, + SystemReserved: nil, + }, + expected: capacity, + }, + { + name: "eviction hard 10 percent", + kubeletConfig: &KubeletConfig{ + EvictionHard: map[string]string{ + "nodefs.available": "10%", + }, + }, + expected: capacity - (capacity / 10), + }, + { + name: "eviction hard absolute 1Gi", + kubeletConfig: &KubeletConfig{ + EvictionHard: map[string]string{ + "nodefs.available": "1Gi", + }, + }, + expected: capacity - (1024 * 1024 * 1024), + }, + { + name: "kube reserved 1Gi", + kubeletConfig: &KubeletConfig{ + KubeReserved: map[string]string{ + "ephemeral-storage": "1Gi", + }, + }, + expected: capacity - (1024 * 1024 * 1024), + }, + { + name: "system reserved 1Gi", + kubeletConfig: &KubeletConfig{ + SystemReserved: map[string]string{ + "ephemeral-storage": "1Gi", + }, + }, + expected: capacity - (1024 * 1024 * 1024), + }, + { + name: "all reservations combined", + kubeletConfig: &KubeletConfig{ + EvictionHard: map[string]string{ + "nodefs.available": "10%", // 10Gi + }, + KubeReserved: map[string]string{ + "ephemeral-storage": "1Gi", + }, + SystemReserved: map[string]string{ + "ephemeral-storage": "2Gi", + }, + }, + expected: capacity - (capacity / 10) - (1024 * 1024 * 1024) - (2 * 1024 * 1024 * 1024), + }, + { + name: "result cannot go negative", + kubeletConfig: &KubeletConfig{ + EvictionHard: map[string]string{ + "nodefs.available": "100%", + }, + KubeReserved: map[string]string{ + "ephemeral-storage": "10Gi", + }, + }, + expected: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + provider := &SysfsStorageInfoProvider{ + log: logging.NewTestLog(), + kubeletConfig: tt.kubeletConfig, + } + + result := provider.calculateAllocatableBytes(capacity) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestReadKubeletConfig(t *testing.T) { + tests := []struct { + name string + configData string + expectError bool + validate func(t *testing.T, config *KubeletConfig) + }{ + { + name: "valid config with all fields", + configData: `evictionHard: + nodefs.available: "10%" + imagefs.available: "15%" +kubeReserved: + cpu: "100m" + memory: "256Mi" + ephemeral-storage: "1Gi" +systemReserved: + cpu: "200m" + memory: "512Mi" + ephemeral-storage: "2Gi" +`, + validate: func(t *testing.T, config *KubeletConfig) { + require.NotNil(t, config) + assert.Equal(t, "10%", config.EvictionHard["nodefs.available"]) + assert.Equal(t, "15%", config.EvictionHard["imagefs.available"]) + assert.Equal(t, "1Gi", config.KubeReserved["ephemeral-storage"]) + assert.Equal(t, "2Gi", config.SystemReserved["ephemeral-storage"]) + }, + }, + { + name: "config with only eviction hard", + configData: `evictionHard: + nodefs.available: "15%" +`, + validate: func(t *testing.T, config *KubeletConfig) { + require.NotNil(t, config) + assert.Equal(t, "15%", config.EvictionHard["nodefs.available"]) + assert.Nil(t, config.KubeReserved) + assert.Nil(t, config.SystemReserved) + }, + }, + { + name: "config with absolute eviction threshold", + configData: `evictionHard: + nodefs.available: "1Gi" +`, + validate: func(t *testing.T, config *KubeletConfig) { + require.NotNil(t, config) + assert.Equal(t, "1Gi", config.EvictionHard["nodefs.available"]) + }, + }, + { + name: "empty config", + configData: "", + validate: func(t *testing.T, config *KubeletConfig) { + require.NotNil(t, config) + assert.Nil(t, config.EvictionHard) + assert.Nil(t, config.KubeReserved) + assert.Nil(t, config.SystemReserved) + }, + }, + { + name: "malformed yaml syntax", + configData: "evictionHard: [not: valid", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "kubelet-config-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + configDir := filepath.Join(tmpDir, "var", "lib", "kubelet") + err = os.MkdirAll(configDir, 0755) + require.NoError(t, err) + + configPath := filepath.Join(configDir, "config.yaml") + err = os.WriteFile(configPath, []byte(tt.configData), 0644) + require.NoError(t, err) + + config, err := readKubeletConfig(tmpDir, logging.NewTestLog()) + + if tt.expectError { + require.Error(t, err) + assert.Nil(t, config) + return + } + + require.NoError(t, err) + if tt.validate != nil { + tt.validate(t, config) + } + }) + } +} + +func TestReadKubeletConfigFileNotFound(t *testing.T) { + config, err := readKubeletConfig("/nonexistent/path", logging.NewTestLog()) + require.Error(t, err) + assert.Nil(t, config) +} + +func TestReadKubeletConfigMultiplePaths(t *testing.T) { + tests := []struct { + name string + files map[string]string + expectedConfig *KubeletConfig + expectError bool + }{ + { + name: "kubeadm YAML config", + files: map[string]string{ + "/var/lib/kubelet/config.yaml": `evictionHard: + nodefs.available: "10%" +kubeReserved: + ephemeral-storage: "1Gi" +`, + }, + expectedConfig: &KubeletConfig{ + EvictionHard: map[string]string{"nodefs.available": "10%"}, + KubeReserved: map[string]string{"ephemeral-storage": "1Gi"}, + }, + }, + { + name: "GKE YAML config", + files: map[string]string{ + "/home/kubernetes/kubelet-config.yaml": `evictionHard: + nodefs.available: "15%" +`, + }, + expectedConfig: &KubeletConfig{ + EvictionHard: map[string]string{"nodefs.available": "15%"}, + }, + }, + { + name: "EKS JSON config", + files: map[string]string{ + "/etc/kubernetes/kubelet/kubelet-config.json": `{ + "evictionHard": {"nodefs.available": "10%"}, + "kubeReserved": {"ephemeral-storage": "2Gi"} +}`, + }, + expectedConfig: &KubeletConfig{ + EvictionHard: map[string]string{"nodefs.available": "10%"}, + KubeReserved: map[string]string{"ephemeral-storage": "2Gi"}, + }, + }, + { + name: "first path takes priority", + files: map[string]string{ + "/var/lib/kubelet/config.yaml": "evictionHard:\n nodefs.available: \"5%\"\n", + "/home/kubernetes/kubelet-config.yaml": "evictionHard:\n nodefs.available: \"15%\"\n", + }, + expectedConfig: &KubeletConfig{ + EvictionHard: map[string]string{"nodefs.available": "5%"}, + }, + }, + { + name: "falls back to second path if first is malformed", + files: map[string]string{ + "/var/lib/kubelet/config.yaml": "evictionHard: [not: valid", + "/home/kubernetes/kubelet-config.yaml": "evictionHard:\n nodefs.available: \"15%\"\n", + }, + expectedConfig: &KubeletConfig{ + EvictionHard: map[string]string{"nodefs.available": "15%"}, + }, + }, + { + name: "no config found", + files: map[string]string{}, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "kubelet-config-multipath-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + for relativePath, content := range tt.files { + fullPath := filepath.Join(tmpDir, relativePath) + err = os.MkdirAll(filepath.Dir(fullPath), 0755) + require.NoError(t, err) + err = os.WriteFile(fullPath, []byte(content), 0644) + require.NoError(t, err) + } + + config, err := readKubeletConfig(tmpDir, logging.NewTestLog()) + + if tt.expectError { + require.Error(t, err) + assert.Nil(t, config) + return + } + + require.NoError(t, err) + require.NotNil(t, config) + + assert.Equal(t, tt.expectedConfig.EvictionHard, config.EvictionHard) + assert.Equal(t, tt.expectedConfig.KubeReserved, config.KubeReserved) + assert.Equal(t, tt.expectedConfig.SystemReserved, config.SystemReserved) + }) + } +} + +func TestGetNodefsCapacityMultiplePaths(t *testing.T) { + tests := []struct { + name string + deviceIDs map[string]uint64 + expectedCapacity int64 + }{ + { + name: "all paths on same filesystem - counted once", + deviceIDs: map[string]uint64{ + kubeletPath: 100, + containerdPath: 100, + castaiStoragePath: 100, + }, + expectedCapacity: 1, + }, + { + name: "kubelet and containerd same, castai-storage different - counted twice", + deviceIDs: map[string]uint64{ + kubeletPath: 100, + containerdPath: 100, + castaiStoragePath: 200, + }, + expectedCapacity: 2, + }, + { + name: "all paths on different filesystems - counted three times", + deviceIDs: map[string]uint64{ + kubeletPath: 100, + containerdPath: 200, + castaiStoragePath: 300, + }, + expectedCapacity: 3, + }, + { + name: "only kubelet exists", + deviceIDs: map[string]uint64{ + kubeletPath: 100, + }, + expectedCapacity: 1, + }, + { + name: "only castai-storage exists", + deviceIDs: map[string]uint64{ + castaiStoragePath: 100, + }, + expectedCapacity: 1, + }, + { + name: "no paths exist", + deviceIDs: map[string]uint64{}, + expectedCapacity: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + provider := &SysfsStorageInfoProvider{ + log: logging.NewTestLog(), + wellKnownPathDeviceID: tt.deviceIDs, + hostRootPath: "/", + } + + paths := []string{kubeletPath, containerdPath, castaiStoragePath} + seenDevices := make(map[uint64]bool) + var uniqueCount int64 + + for _, path := range paths { + devID, ok := provider.wellKnownPathDeviceID[path] + if !ok { + continue + } + if seenDevices[devID] { + continue + } + seenDevices[devID] = true + uniqueCount++ + } + + assert.Equal(t, tt.expectedCapacity, uniqueCount) + }) + } +} + +func TestBuildFilesystemLabels(t *testing.T) { + tests := []struct { + name string + fsMountPointDeviceID uint64 + wellKnownPathsDeviceID map[string]uint64 + expectedLabels map[string]string + }{ + { + name: "filesystem matches kubelet path", + fsMountPointDeviceID: 100, + wellKnownPathsDeviceID: map[string]uint64{ + kubeletPath: 100, + }, + expectedLabels: map[string]string{ + "kubelet": "true", + }, + }, + { + name: "filesystem matches containerd path", + fsMountPointDeviceID: 100, + wellKnownPathsDeviceID: map[string]uint64{ + containerdPath: 100, + }, + expectedLabels: map[string]string{ + "containerd": "true", + }, + }, + { + name: "filesystem matches castai-storage path", + fsMountPointDeviceID: 100, + wellKnownPathsDeviceID: map[string]uint64{ + castaiStoragePath: 100, + }, + expectedLabels: map[string]string{ + "castai-storage": "true", + }, + }, + { + name: "filesystem matches multiple paths on same device", + fsMountPointDeviceID: 100, + wellKnownPathsDeviceID: map[string]uint64{ + kubeletPath: 100, + containerdPath: 100, + castaiStoragePath: 100, + }, + expectedLabels: map[string]string{ + "kubelet": "true", + "containerd": "true", + "castai-storage": "true", + }, + }, + { + name: "filesystem matches kubelet and containerd but not castai-storage", + fsMountPointDeviceID: 100, + wellKnownPathsDeviceID: map[string]uint64{ + kubeletPath: 100, + containerdPath: 100, + castaiStoragePath: 200, + }, + expectedLabels: map[string]string{ + "kubelet": "true", + "containerd": "true", + }, + }, + { + name: "filesystem matches no paths", + fsMountPointDeviceID: 999, + wellKnownPathsDeviceID: map[string]uint64{ + kubeletPath: 100, + containerdPath: 200, + castaiStoragePath: 300, + }, + expectedLabels: map[string]string{}, + }, + { + name: "empty wellKnownPathsDeviceID", + fsMountPointDeviceID: 100, + wellKnownPathsDeviceID: map[string]uint64{}, + expectedLabels: map[string]string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + labels := buildFilesystemLabels(logging.NewTestLog(), tt.fsMountPointDeviceID, tt.wellKnownPathsDeviceID) + assert.Equal(t, tt.expectedLabels, labels) + }) + } +} From 66074d490748190ba247aace12479697f9979858 Mon Sep 17 00:00:00 2001 From: Krystian Bednarczuk Date: Thu, 29 Jan 2026 11:02:02 +0100 Subject: [PATCH 2/2] fix tests --- .../daemon/pipeline/storage_info_provider.go | 2 +- .../pipeline/storage_info_provider_test.go | 70 ++++++------------- 2 files changed, 23 insertions(+), 49 deletions(-) diff --git a/cmd/agent/daemon/pipeline/storage_info_provider.go b/cmd/agent/daemon/pipeline/storage_info_provider.go index 93e65248..bb18bf26 100644 --- a/cmd/agent/daemon/pipeline/storage_info_provider.go +++ b/cmd/agent/daemon/pipeline/storage_info_provider.go @@ -1213,7 +1213,7 @@ func (s *SysfsStorageInfoProvider) calculateAllocatableBytes(capacityBytes int64 // getNodefsCapacity returns the total capacity across all well-known storage paths, // deduplicating by device ID to avoid counting the same filesystem multiple times. func (s *SysfsStorageInfoProvider) getNodefsCapacity() int64 { - paths := []string{kubeletPath, containerdPath, castaiStoragePath} + paths := []string{kubeletPath, containerdPath} seenDevices := make(map[uint64]bool) var totalCapacity int64 diff --git a/cmd/agent/daemon/pipeline/storage_info_provider_test.go b/cmd/agent/daemon/pipeline/storage_info_provider_test.go index 1a34c8b4..c1d5149f 100644 --- a/cmd/agent/daemon/pipeline/storage_info_provider_test.go +++ b/cmd/agent/daemon/pipeline/storage_info_provider_test.go @@ -1080,32 +1080,21 @@ func TestGetNodefsCapacityMultiplePaths(t *testing.T) { expectedCapacity int64 }{ { - name: "all paths on same filesystem - counted once", + name: "kubelet and containerd on same filesystem - counted once", deviceIDs: map[string]uint64{ - kubeletPath: 100, - containerdPath: 100, - castaiStoragePath: 100, + kubeletPath: 100, + containerdPath: 100, }, expectedCapacity: 1, }, { - name: "kubelet and containerd same, castai-storage different - counted twice", + name: "kubelet and containerd on different filesystems - counted twice", deviceIDs: map[string]uint64{ - kubeletPath: 100, - containerdPath: 100, - castaiStoragePath: 200, + kubeletPath: 100, + containerdPath: 200, }, expectedCapacity: 2, }, - { - name: "all paths on different filesystems - counted three times", - deviceIDs: map[string]uint64{ - kubeletPath: 100, - containerdPath: 200, - castaiStoragePath: 300, - }, - expectedCapacity: 3, - }, { name: "only kubelet exists", deviceIDs: map[string]uint64{ @@ -1114,9 +1103,9 @@ func TestGetNodefsCapacityMultiplePaths(t *testing.T) { expectedCapacity: 1, }, { - name: "only castai-storage exists", + name: "only containerd exists", deviceIDs: map[string]uint64{ - castaiStoragePath: 100, + containerdPath: 100, }, expectedCapacity: 1, }, @@ -1135,7 +1124,7 @@ func TestGetNodefsCapacityMultiplePaths(t *testing.T) { hostRootPath: "/", } - paths := []string{kubeletPath, containerdPath, castaiStoragePath} + paths := []string{kubeletPath, containerdPath} seenDevices := make(map[uint64]bool) var uniqueCount int64 @@ -1184,49 +1173,34 @@ func TestBuildFilesystemLabels(t *testing.T) { }, }, { - name: "filesystem matches castai-storage path", + name: "filesystem matches kubelet and containerd on same device", fsMountPointDeviceID: 100, wellKnownPathsDeviceID: map[string]uint64{ - castaiStoragePath: 100, - }, - expectedLabels: map[string]string{ - "castai-storage": "true", - }, - }, - { - name: "filesystem matches multiple paths on same device", - fsMountPointDeviceID: 100, - wellKnownPathsDeviceID: map[string]uint64{ - kubeletPath: 100, - containerdPath: 100, - castaiStoragePath: 100, + kubeletPath: 100, + containerdPath: 100, }, expectedLabels: map[string]string{ - "kubelet": "true", - "containerd": "true", - "castai-storage": "true", + "kubelet": "true", + "containerd": "true", }, }, { - name: "filesystem matches kubelet and containerd but not castai-storage", + name: "filesystem matches kubelet but not containerd", fsMountPointDeviceID: 100, wellKnownPathsDeviceID: map[string]uint64{ - kubeletPath: 100, - containerdPath: 100, - castaiStoragePath: 200, + kubeletPath: 100, + containerdPath: 200, }, expectedLabels: map[string]string{ - "kubelet": "true", - "containerd": "true", + "kubelet": "true", }, }, { - name: "filesystem matches no paths", - fsMountPointDeviceID: 999, + name: "filesystem matches no paths", + fsMountPointDeviceID: 999, wellKnownPathsDeviceID: map[string]uint64{ - kubeletPath: 100, - containerdPath: 200, - castaiStoragePath: 300, + kubeletPath: 100, + containerdPath: 200, }, expectedLabels: map[string]string{}, },