diff --git a/internal/config/env.go b/internal/config/env.go index ed14673c6..86e58b4d1 100644 --- a/internal/config/env.go +++ b/internal/config/env.go @@ -461,15 +461,7 @@ func init() { Consts.NewContainersLimit = 1000 // virtually no limit for now Consts.PeriodicDrivesCheckInterval = 1 * time.Minute Consts.CheckDriversInterval = 7 * time.Minute - // Default minimum drive/compute containers required to form a cluster. The 5-container default - // suits production 3+2+1 (minFdNum=6); a single-parity 2+1 cluster (minFdNum=3) legitimately - // forms with as few as 3, so AllowSingleParity lowers the default. Both remain env-overridable. - formClusterMinDefault := 5 - if getBoolEnvOrDefault("ALLOW_SINGLE_PARITY", false) { - formClusterMinDefault = 3 - } - Consts.FormClusterMinComputeContainers = getIntEnvOrDefault("FORM_CLUSTER_MIN_COMPUTE_CONTAINERS", formClusterMinDefault) - Consts.FormClusterMinDriveContainers = getIntEnvOrDefault("FORM_CLUSTER_MIN_DRIVE_CONTAINERS", formClusterMinDefault) + loadFormClusterMinContainers() Consts.FormClusterMaxComputeContainers = 10 Consts.FormClusterMaxDriveContainers = 10 Consts.FormS3ClusterMaxContainerCount = 3 @@ -487,6 +479,18 @@ func init() { Consts.SsdProxyDpdkMemoryMiB = 2048 } +func loadFormClusterMinContainers() { + // The 5-container default suits production 3+2+1 (minFdNum=6); a single-parity 2+1 cluster + // (minFdNum=3) legitimately forms with as few as 3, so AllowSingleParity lowers the default. + // Both remain env-overridable. + formClusterMinDefault := 5 + if getBoolEnvOrDefault("ALLOW_SINGLE_PARITY", false) { + formClusterMinDefault = 3 + } + Consts.FormClusterMinComputeContainers = getIntEnvOrDefault("FORM_CLUSTER_MIN_COMPUTE_CONTAINERS", formClusterMinDefault) + Consts.FormClusterMinDriveContainers = getIntEnvOrDefault("FORM_CLUSTER_MIN_DRIVE_CONTAINERS", formClusterMinDefault) +} + // LoadCapacityEnv populates the drive-sharing, cluster-capacity and compute-hugepages configuration // from environment variables, with the built-in defaults. It is the single source of these defaults, // shared by ConfigureEnv (the operator) and standalone callers such as the weka-capacity dry-run CLI, @@ -519,6 +523,15 @@ func LoadCapacityEnv() { // Compute hugepages cap Config.ComputeMaxHugepagesMiB = getIntEnvOrDefault("COMPUTE_MAX_HUGEPAGES_MIB", 360000) + + // Physical-CPU accounting: read here (not just ConfigureEnv) so standalone callers like the + // weka-capacity CLI, which never call ConfigureEnv, still get HT-aware core counting. + Config.FullPcpusOnly = getBoolEnvOrDefault("FULL_PCPUS_ONLY", false) + + // Re-derive from whatever ALLOW_SINGLE_PARITY/FORM_CLUSTER_MIN_* the caller has set by now: standalone + // callers such as the weka-capacity CLI overlay the operator's env via os.Setenv and call only + // LoadCapacityEnv, long after this package's init() already ran against the CLI's own environment. + loadFormClusterMinContainers() } func ConfigureEnv(ctx context.Context) { @@ -599,7 +612,6 @@ func ConfigureEnv(ctx context.Context) { Config.DNSPolicy.HostNetwork = env.GetString("DNS_POLICY_HOST_NETWORK", "") Config.SignDrivesImage = env.GetString("SIGN_DRIVES_IMAGE", "") Config.TaskmonDefaultImage = env.GetString("TASKMON_DEFAULT_IMAGE", "") - Config.FullPcpusOnly = getBoolEnvOrDefault("FULL_PCPUS_ONLY", false) Config.SkipUnhealthyToleration = getBoolEnvOrDefault("SKIP_UNHEALTHY_TOLERATION", false) Config.SkipClientNoScheduleToleration = getBoolEnvOrDefault("SKIP_CLIENT_NO_SCHEDULE_TOLERATION", false) Config.SkipAuxNoScheduleToleration = getBoolEnvOrDefault("SKIP_AUX_NO_SCHEDULE_TOLERATION", false) diff --git a/internal/config/env_test.go b/internal/config/env_test.go index 2bc5ee8ea..cf189ea66 100644 --- a/internal/config/env_test.go +++ b/internal/config/env_test.go @@ -8,14 +8,14 @@ import ( func TestEffectiveProtection(t *testing.T) { tests := []struct { - name string - cfg config.DriveSharingConfig - specSW int - specRL int - specHS int - wantSW int - wantRL int - wantHS int + name string + cfg config.DriveSharingConfig + specSW int + specRL int + specHS int + wantSW int + wantRL int + wantHS int }{ { name: "all-zero spec and all-zero defaults yields zero", @@ -53,3 +53,27 @@ func TestEffectiveProtection(t *testing.T) { }) } } + +// TestLoadCapacityEnv_RederivesFormClusterMinimumsAndFullPcpus reproduces the weka-capacity CLI's +// startup order: it scrapes the operator's env and applies it via os.Setenv AFTER this package's +// init() already ran against the CLI's own process environment, then calls only LoadCapacityEnv +// (never ConfigureEnv/init() again). Both the ALLOW_SINGLE_PARITY-lowered form-cluster minimums and +// FullPcpusOnly must therefore be re-derived by LoadCapacityEnv itself, not only by init()/ConfigureEnv. +func TestLoadCapacityEnv_RederivesFormClusterMinimumsAndFullPcpus(t *testing.T) { + t.Setenv("ALLOW_SINGLE_PARITY", "true") + t.Setenv("FORM_CLUSTER_MIN_COMPUTE_CONTAINERS", "") + t.Setenv("FORM_CLUSTER_MIN_DRIVE_CONTAINERS", "") + t.Setenv("FULL_PCPUS_ONLY", "true") + + config.LoadCapacityEnv() + + if config.Consts.FormClusterMinComputeContainers != 3 { + t.Errorf("FormClusterMinComputeContainers = %d, want 3 (ALLOW_SINGLE_PARITY-lowered default)", config.Consts.FormClusterMinComputeContainers) + } + if config.Consts.FormClusterMinDriveContainers != 3 { + t.Errorf("FormClusterMinDriveContainers = %d, want 3 (ALLOW_SINGLE_PARITY-lowered default)", config.Consts.FormClusterMinDriveContainers) + } + if !config.Config.FullPcpusOnly { + t.Error("Config.FullPcpusOnly = false, want true: LoadCapacityEnv must read FULL_PCPUS_ONLY for CLI callers") + } +} diff --git a/internal/consts/consts.go b/internal/consts/consts.go index 9a196bf2c..72d573309 100644 --- a/internal/consts/consts.go +++ b/internal/consts/consts.go @@ -23,18 +23,17 @@ const WekaContainerName = "weka-container" // Node annotation keys for drive management const ( - // AnnotationWekaDrives stores drive serial IDs for non-proxy mode. - // Format: ["SERIAL1", "SERIAL2", ...] - // Deprecated for writing: use AnnotationWekaFullDrives instead. Kept for backward compatibility reading. + // AnnotationWekaDrives stores drive serial IDs for non-proxy mode: ["SERIAL1", "SERIAL2", ...]. + // Deprecated for writing: use AnnotationWekaFullDrives; kept for backward-compat reading. AnnotationWekaDrives = "weka.io/weka-drives" - // AnnotationWekaFullDrives stores drive entries with full metadata (serial + capacity_gib) for non-proxy mode. - // Format: [{"serial":"SERIAL1","capacity_gib":14307},...] - // This supersedes AnnotationWekaDrives which is deprecated for writing but still supported for reading (fallback). + // AnnotationWekaFullDrives stores drive entries with full metadata for non-proxy mode: + // [{"serial":"SERIAL1","capacity_gib":14307},...]. Supersedes AnnotationWekaDrives (still read + // as fallback). TLC drives only: full-drives mode has no QLC accounting, so discovery excludes + // QLC drives here and every consumer charges these entries as TLC. AnnotationWekaFullDrives = "weka.io/weka-full-drives" - // AnnotationBlockedDrives stores blocked drive serial IDs (non-proxy mode) - // Format: ["SERIAL1", "SERIAL2", ...] + // AnnotationBlockedDrives stores blocked drive serial IDs (non-proxy mode): ["SERIAL1", ...]. AnnotationBlockedDrives = "weka.io/blocked-drives" // AnnotationSharedDrives stores shared drive information for proxy mode @@ -62,8 +61,7 @@ const ( // Format: ["uuid1", "uuid2", ...] AnnotationBlockedDrivesVirtualUuids = "weka.io/blocked-drives-virtual-uuids" - // AnnotationSignDrivesHash stores hash of signed drives to track changes - // Used to determine if drives need to be re-signed + // AnnotationSignDrivesHash stores a hash of signed drives, used to detect when re-signing is needed. AnnotationSignDrivesHash = "weka.io/sign-drives-hash" ) @@ -77,13 +75,14 @@ const PodConfigCodeVersion = "1" // Kubernetes extended resource names const ( - // ResourceDrives is the extended resource name for tracking available drives (non-proxy mode) + // ResourceDrives tracks available drives (non-proxy mode). TLC only: it counts the non-blocked + // entries of AnnotationWekaFullDrives, which excludes QLC. ResourceDrives = "weka.io/drives" - // ResourceSharedDrivesCapacity is the extended resource name for tracking shared drive capacity (proxy mode) + // ResourceSharedDrivesCapacity tracks shared drive capacity (proxy mode). ResourceSharedDrivesCapacity = "weka.io/shared-drives-capacity" - // ResourceSharedDrivesCapacityTLC is the extended resource name for tracking shared drive capacity of QLC drives (proxy mode) + // ResourcesSharedDrivesCapacityQLC tracks shared drive capacity of QLC drives (proxy mode). ResourcesSharedDrivesCapacityQLC = "weka.io/shared-drives-capacity-qlc" // WekaNumaRegionResourcePrefix is the extended resource name prefix for NUMA region confinement diff --git a/internal/controllers/operations/enable_local_drivers_distribution.go b/internal/controllers/operations/enable_local_drivers_distribution.go index e206be74a..3f2444b73 100644 --- a/internal/controllers/operations/enable_local_drivers_distribution.go +++ b/internal/controllers/operations/enable_local_drivers_distribution.go @@ -26,6 +26,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "github.com/weka/weka-operator/internal/controllers/resources" "github.com/weka/weka-operator/internal/drivers" "github.com/weka/weka-operator/internal/services/discovery" "github.com/weka/weka-operator/internal/services/kubernetes" @@ -518,7 +519,7 @@ func (o *EnsureDistServiceOperation) DeleteIfNodeNotReady(ctx context.Context, c return fmt.Errorf("failed to get node %s: %w", nodeName, err) } - if NodeNotReady(node) { + if !resources.NodeIsReady(node) { logger.Info("Node is not ready, deleting dist container", "container", wc.Name, "node", nodeName) deleteErr := o.client.Delete(ctx, container) @@ -906,15 +907,3 @@ func (o *EnsureDistServiceOperation) AsStep() lifecycle.Step { Run: AsRunFunc(o), // Assuming AsRunFunc helper exists } } - -func NodeNotReady(node *corev1.Node) bool { - if node == nil { - return true // If node is nil, consider it not ready - } - for _, condition := range node.Status.Conditions { - if condition.Type == corev1.NodeReady && condition.Status != corev1.ConditionTrue { - return true // Node is not ready - } - } - return false // Node is ready -} diff --git a/internal/controllers/operations/prepull_utils.go b/internal/controllers/operations/prepull_utils.go index 85b063fd7..1067a6644 100644 --- a/internal/controllers/operations/prepull_utils.go +++ b/internal/controllers/operations/prepull_utils.go @@ -12,7 +12,6 @@ import ( "github.com/weka/weka-operator/internal/config" "github.com/weka/weka-operator/internal/controllers/resources" - "github.com/weka/weka-operator/pkg/util" ) const ( @@ -154,8 +153,8 @@ type PrePullStatusResult struct { AllReady bool } -// GetTargetNodes returns nodes that match the given selectors and tolerations -// Nodes must be Ready and not Unschedulable +// GetTargetNodes returns nodes matching nodeSelector that can currently host a new weka pod: not +// cordoned, Ready, and carrying no taint outside tolerations (resources.NodeIneligibleReason). func GetTargetNodes(ctx context.Context, c client.Client, nodeSelector map[string]string, tolerations []corev1.Toleration) ([]corev1.Node, error) { nodeList := &corev1.NodeList{} listOpts := []client.ListOption{} @@ -169,19 +168,9 @@ func GetTargetNodes(ctx context.Context, c client.Client, nodeSelector map[strin var targetNodes []corev1.Node for i := range nodeList.Items { - // Skip unschedulable nodes - if nodeList.Items[i].Spec.Unschedulable { + if resources.NodeIneligibleReason(&nodeList.Items[i], tolerations) != "" { continue } - // Skip nodes that are not Ready - if NodeNotReady(&nodeList.Items[i]) { - continue - } - // Check if tolerations match node taints - if !util.CheckTolerations(nodeList.Items[i].Spec.Taints, tolerations, nil) { - continue - } - targetNodes = append(targetNodes, nodeList.Items[i]) } diff --git a/internal/controllers/operations/prepull_utils_test.go b/internal/controllers/operations/prepull_utils_test.go new file mode 100644 index 000000000..33a02c48d --- /dev/null +++ b/internal/controllers/operations/prepull_utils_test.go @@ -0,0 +1,54 @@ +package operations + +import ( + "context" + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func readyNode(name string) *corev1.Node { + return &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + Status: corev1.NodeStatus{Conditions: []corev1.NodeCondition{{Type: corev1.NodeReady, Status: corev1.ConditionTrue}}}, + } +} + +// TestGetTargetNodes_NodeEligibility exercises GetTargetNodes' node filtering, which now runs entirely +// through resources.NodeIneligibleReason: cordoned and untolerated-taint nodes are excluded like before, +// and a node reporting no NodeReady condition at all (not just NodeReady=False) is excluded too. +func TestGetTargetNodes_NodeEligibility(t *testing.T) { + scheme := runtime.NewScheme() + if err := corev1.AddToScheme(scheme); err != nil { + t.Fatalf("AddToScheme: %v", err) + } + + cordoned := readyNode("cordoned") + cordoned.Spec.Unschedulable = true + + noReadyCondition := readyNode("no-ready-condition") + noReadyCondition.Status.Conditions = nil + + notReady := readyNode("not-ready") + notReady.Status.Conditions = []corev1.NodeCondition{{Type: corev1.NodeReady, Status: corev1.ConditionFalse}} + + eligible := readyNode("eligible") + + fakeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(cordoned, noReadyCondition, notReady, eligible).Build() + + got, err := GetTargetNodes(context.Background(), fakeClient, nil, nil) + if err != nil { + t.Fatalf("GetTargetNodes: %v", err) + } + + var names []string + for _, n := range got { + names = append(names, n.Name) + } + if len(names) != 1 || names[0] != "eligible" { + t.Errorf("GetTargetNodes returned %v, want only [eligible]", names) + } +} diff --git a/internal/controllers/resources/node.go b/internal/controllers/resources/node.go index d4ca072db..1156be47b 100644 --- a/internal/controllers/resources/node.go +++ b/internal/controllers/resources/node.go @@ -1,12 +1,18 @@ package resources -import v1 "k8s.io/api/core/v1" +import ( + v1 "k8s.io/api/core/v1" + "github.com/weka/weka-operator/pkg/util" +) + +// NodeIsReady reports whether node carries a NodeReady=True condition. A node that has not reported a +// NodeReady condition at all (nil node, or the condition simply absent) reads as NOT ready — it has not +// yet told us it can run pods, so it is not a safe placement target. func NodeIsReady(node *v1.Node) bool { if node == nil { return false } - // check if the node has a NodeReady condition set to True isNodeReady := false for _, condition := range node.Status.Conditions { if condition.Type == v1.NodeReady && condition.Status == v1.ConditionTrue { @@ -16,3 +22,23 @@ func NodeIsReady(node *v1.Node) bool { } return isNodeReady } + +// NodeIneligibleReason reports why a node cannot host a new weka pod right now — cordoned, not ready, or +// carrying a taint outside tolerations — or "" when the node is a valid placement candidate. This is the +// single predicate for "can this node receive a new pod": every caller across the operator and CLI that +// needs this check goes through it, so the classifications can never quietly diverge between call sites. +func NodeIneligibleReason(node *v1.Node, tolerations []v1.Toleration) string { + if node == nil { + return "not ready" + } + if node.Spec.Unschedulable { + return "cordoned" + } + if !NodeIsReady(node) { + return "not ready" + } + if !util.CheckTolerations(node.Spec.Taints, tolerations, nil) { + return "untolerated taint" + } + return "" +} diff --git a/internal/controllers/resources/node_test.go b/internal/controllers/resources/node_test.go new file mode 100644 index 000000000..fbfda29ae --- /dev/null +++ b/internal/controllers/resources/node_test.go @@ -0,0 +1,109 @@ +package resources + +import ( + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func readyNode(name string) *corev1.Node { + return &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + Status: corev1.NodeStatus{Conditions: []corev1.NodeCondition{{Type: corev1.NodeReady, Status: corev1.ConditionTrue}}}, + } +} + +func TestNodeIsReady(t *testing.T) { + tests := []struct { + name string + node *corev1.Node + want bool + }{ + {name: "nil node", node: nil, want: false}, + { + name: "NodeReady=True", + node: readyNode("n1"), + want: true, + }, + { + name: "NodeReady=False", + node: &corev1.Node{Status: corev1.NodeStatus{Conditions: []corev1.NodeCondition{{Type: corev1.NodeReady, Status: corev1.ConditionFalse}}}}, + want: false, + }, + { + name: "no conditions at all: kubelet has not reported readiness yet", + node: &corev1.Node{}, + want: false, + }, + { + name: "conditions present but no NodeReady entry", + node: &corev1.Node{Status: corev1.NodeStatus{Conditions: []corev1.NodeCondition{{Type: corev1.NodeDiskPressure, Status: corev1.ConditionFalse}}}}, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := NodeIsReady(tt.node); got != tt.want { + t.Errorf("NodeIsReady() = %v, want %v", got, tt.want) + } + }) + } +} + +// TestNodeIneligibleReason covers every classification NodeIneligibleReason makes, table-driven since each +// row is the same shape: build a node, call the function, compare the reason string. This is the single +// predicate shared by capacityplanner/inventory (NodeInventory/FullDrivesInventory/ExploreNodes) and +// controllers/operations (GetTargetNodes). +func TestNodeIneligibleReason(t *testing.T) { + gpuTaint := corev1.Taint{Key: "dedicated", Value: "gpu", Effect: corev1.TaintEffectNoSchedule} + gpuToleration := corev1.Toleration{Key: "dedicated", Operator: corev1.TolerationOpEqual, Value: "gpu", Effect: corev1.TaintEffectNoSchedule} + + tests := []struct { + name string + mutate func(n *corev1.Node) + tolerations []corev1.Toleration + want string + }{ + {name: "all eligible: ready, no taints, not cordoned", mutate: func(n *corev1.Node) {}, want: ""}, + { + name: "cordoned: flagged regardless of readiness or taints", + mutate: func(n *corev1.Node) { n.Spec.Unschedulable = true }, + want: "cordoned", + }, + { + name: "not ready: NodeReady=False", + mutate: func(n *corev1.Node) { + n.Status.Conditions = []corev1.NodeCondition{{Type: corev1.NodeReady, Status: corev1.ConditionFalse}} + }, + want: "not ready", + }, + { + name: "not ready: no NodeReady condition at all", + mutate: func(n *corev1.Node) { + n.Status.Conditions = nil + }, + want: "not ready", + }, + { + name: "untolerated taint: caller's toleration set does not cover the NoSchedule taint", + mutate: func(n *corev1.Node) { n.Spec.Taints = []corev1.Taint{gpuTaint} }, + want: "untolerated taint", + }, + { + name: "tolerated taint: caller's toleration set covers it, reads as eligible", + mutate: func(n *corev1.Node) { n.Spec.Taints = []corev1.Taint{gpuTaint} }, + tolerations: []corev1.Toleration{gpuToleration}, + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + n1 := readyNode("n1") + tt.mutate(n1) + if got := NodeIneligibleReason(n1, tt.tolerations); got != tt.want { + t.Errorf("NodeIneligibleReason(n1) = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/internal/controllers/resources/pod.go b/internal/controllers/resources/pod.go index c8ba61ac7..82f6f9aa3 100644 --- a/internal/controllers/resources/pod.go +++ b/internal/controllers/resources/pod.go @@ -13,6 +13,7 @@ import ( "github.com/weka/go-weka-observability/instrumentation" weka "github.com/weka/weka-k8s-api/api/v1alpha1" + k8sapiutil "github.com/weka/weka-k8s-api/util" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -35,7 +36,7 @@ const ( kubectlAnnotationPrefix = "kubectl.kubernetes.io/" ) -// if the container mode is not in the map, the default is 1 year +// terminationGracePeriodSecondsMap gives per-mode grace periods; modes absent from it default to 1 year. var terminationGracePeriodSecondsMap = map[string]int64{ weka.WekaContainerModeDiscovery: 30, weka.WekaContainerModeDist: 60 * 5, @@ -98,8 +99,7 @@ func (f *PodFactory) Create(ctx context.Context, podImage *string) (*corev1.Pod, annotations := AnnotationsForWekaPod(f.container.GetAnnotations(), nil) image := f.container.Spec.Image - // if podImage is not nil, use it instead of the image from the container spec - // NOTE: used for the cases when it's not allowed to upgrade weka image + // podImage overrides the spec image; used when upgrading the weka image isn't allowed. if podImage != nil { image = *podImage } @@ -118,7 +118,6 @@ func (f *PodFactory) Create(ctx context.Context, podImage *string) (*corev1.Pod, netDevice := "udp" udpMode := "false" subnets := strings.Join(f.container.Spec.Network.DeviceSubnets, ",") - // convert f.container.Spec.Network.ManagementIPsSelectors to json string managementIPsSelectors := "" if len(f.container.Spec.Network.ManagementIPsSelectors) > 0 { managementIPsSelectorsBytes, err := json.Marshal(f.container.Spec.Network.ManagementIPsSelectors) @@ -127,7 +126,6 @@ func (f *PodFactory) Create(ctx context.Context, podImage *string) (*corev1.Pod, } managementIPsSelectors = string(managementIPsSelectorsBytes) } - // convert f.container.Spec.Network.Selectors to json string networkSelectors := "" if len(f.container.Spec.Network.Selectors) > 0 { networkSelectorsBytes, err := json.Marshal(f.container.Spec.Network.Selectors) @@ -440,7 +438,6 @@ func (f *PodFactory) Create(ctx context.Context, podImage *string) (*corev1.Pod, Name: "SYSLOG_PACKAGE", Value: config.Config.SyslogPackage, }, - // OpenTelemetry configuration { Name: "OTEL_EXPORTER_OTLP_ENDPOINT", Value: config.Config.Otel.ExporterOtlpEndpoint, @@ -535,7 +532,6 @@ func (f *PodFactory) Create(ctx context.Context, podImage *string) (*corev1.Pod, } if f.container.Spec.GetOverrides().PreRunScript != "" { - // encode in base64 and write into env var base64str := base64.StdEncoding.EncodeToString([]byte(f.container.Spec.GetOverrides().PreRunScript)) pod.Spec.Containers[0].Env = append(pod.Spec.Containers[0].Env, corev1.EnvVar{ Name: "PRE_RUN_SCRIPT", @@ -544,7 +540,6 @@ func (f *PodFactory) Create(ctx context.Context, podImage *string) (*corev1.Pod, } if f.container.Spec.PortRange != nil { - // vars needed for clients to dynamically set ports envVars := []corev1.EnvVar{ { Name: "BASE_PORT", @@ -1010,7 +1005,10 @@ func getPressureTolerations() []corev1.Toleration { } } -func GetWekaPodTolerations(container *weka.WekaContainer) []corev1.Toleration { +// wekaBaseTolerations returns the tolerations every Weka pod gets regardless of container mode: always +// shutdown-node, conditionally node-health (SkipUnhealthyToleration), always pressure. Shared by +// GetWekaPodTolerations and GetWekaPodTolerationsForCluster so the two can never drift apart. +func wekaBaseTolerations() []corev1.Toleration { tolerations := []corev1.Toleration{ { Key: "weka.io/shutdown-node", @@ -1023,8 +1021,11 @@ func GetWekaPodTolerations(container *weka.WekaContainer) []corev1.Toleration { tolerations = append(tolerations, getUnhealthyTolerations()...) } - pressureTolerations := getPressureTolerations() - tolerations = append(tolerations, pressureTolerations...) + return append(tolerations, getPressureTolerations()...) +} + +func GetWekaPodTolerations(container *weka.WekaContainer) []corev1.Toleration { + tolerations := wekaBaseTolerations() if !config.Config.SkipClientNoScheduleToleration && container.Spec.Mode == weka.WekaContainerModeClient { tolerations = ExpandNoScheduleTolerations(tolerations) @@ -1045,8 +1046,24 @@ func GetWekaPodTolerations(container *weka.WekaContainer) []corev1.Toleration { return tolerations } -// getSsdUidForAdhocOp returns true if this is an adhoc operation -// that needs access to the ssdproxy socket directory (sign-drives with shared=true) +// WekaPodBaseTolerations exports wekaBaseTolerations for callers with neither a live container nor a +// cluster to read custom tolerations from (the capacity planner's cluster-agnostic explore-nodes view). +func WekaPodBaseTolerations() []corev1.Toleration { return wekaBaseTolerations() } + +// GetWekaPodTolerationsForCluster returns the tolerations a drive or compute container's pod would get for +// cluster, without a live container object. The capacity planner needs this at inventory-collection time, +// before any container exists, to decide which nodes it can actually schedule onto. It is faithful for +// drive and compute containers specifically: GetWekaPodTolerations' only container-dependent behavior +// besides a container's own custom tolerations is the client/aux-mode NoSchedule expansion, and drive/ +// compute containers never have that mode, so their tolerations reduce to exactly this — the base set plus +// the cluster's tolerations/rawTolerations, the same inputs NewWekaContainerForWekaCluster +// (internal/controllers/factory/container_factory.go) copies onto a new container's Spec.Tolerations. +func GetWekaPodTolerationsForCluster(cluster *weka.WekaCluster) []corev1.Toleration { + return k8sapiutil.ExpandTolerations(wekaBaseTolerations(), cluster.Spec.Tolerations, cluster.Spec.RawTolerations) +} + +// getSsdUidForAdhocOp returns the ssdproxy container UUID for a sign-drives adhoc op with +// shared=true, so the pod can be given access to that ssdproxy's socket; nil otherwise. func (f *PodFactory) getSsdUidForAdhocOp() *string { if !f.container.IsAdhocOpContainer() { return nil @@ -1471,7 +1488,8 @@ func (f *PodFactory) setResources(ctx context.Context, pod *corev1.Pod, hgDetail } if f.container.Spec.Mode == weka.WekaContainerModeDrive && !f.container.UsesDriveSharing() { - // Regular drive mode: request exclusive drives (count) + // TLC drives only — weka.io/drives counts the node's weka-full-drives entries, which exclude + // QLC (full-drives mode has no QLC accounting). QLC is only usable via drive sharing. pod.Spec.Containers[0].Resources.Requests[consts.ResourceDrives] = resource.MustParse(strconv.Itoa(f.container.Spec.NumDrives)) pod.Spec.Containers[0].Resources.Limits[consts.ResourceDrives] = resource.MustParse(strconv.Itoa(f.container.Spec.NumDrives)) } else if f.container.Spec.Mode == weka.WekaContainerModeDrive && f.container.UsesDriveSharing() { @@ -1686,7 +1704,6 @@ func (f *PodFactory) setAffinities(ctx context.Context, pod *corev1.Pod) error { } - // generalize above code using mode if pod.Spec.Affinity.PodAntiAffinity == nil { pod.Spec.Affinity.PodAntiAffinity = &corev1.PodAntiAffinity{ RequiredDuringSchedulingIgnoredDuringExecution: []corev1.PodAffinityTerm{term}, diff --git a/internal/controllers/resources/pod_cpu_alignment_test.go b/internal/controllers/resources/pod_cpu_alignment_test.go index 652d9355c..f3fe8a9c8 100644 --- a/internal/controllers/resources/pod_cpu_alignment_test.go +++ b/internal/controllers/resources/pod_cpu_alignment_test.go @@ -77,8 +77,8 @@ func TestFullPcpusOnlyCPUAlignment(t *testing.T) { name string numCores int isHt bool - forceFullPcpus bool // config.Config.FullPcpusOnly (operator-wide force) - nodeFullPcpusOnly bool // f.nodeInfo.NodeFullPcpusOnly (auto-detected on the node) + forceFullPcpus bool // config.Config.FullPcpusOnly (operator-wide force) + nodeFullPcpusOnly bool // f.nodeInfo.NodeFullPcpusOnly (auto-detected on the node) wantCPU string // expected value of pod CPU request/limit }{ { @@ -92,7 +92,7 @@ func TestFullPcpusOnlyCPUAlignment(t *testing.T) { numCores: 2, isHt: true, forceFullPcpus: true, - wantCPU: "6", // 5 is odd, +1 → 6 + wantCPU: "6", }, { name: "HT, auto-detected on node → round up 5→6", @@ -106,7 +106,7 @@ func TestFullPcpusOnlyCPUAlignment(t *testing.T) { numCores: 2, isHt: false, forceFullPcpus: true, - wantCPU: "5", // IsHt=false → guard condition false + wantCPU: "5", }, { name: "numCores=3, HT, forced → round up 7→8", diff --git a/internal/controllers/utils/pod_status.go b/internal/controllers/utils/pod_status.go new file mode 100644 index 000000000..8ac23f507 --- /dev/null +++ b/internal/controllers/utils/pod_status.go @@ -0,0 +1,27 @@ +package utils + +import ( + v1 "k8s.io/api/core/v1" +) + +// PodUnschedulableCondition returns the PodScheduled condition explicitly reporting +// Reason == "Unschedulable", or nil. Callers that need to know how LONG the pod has been unschedulable, +// or the scheduler's own explanation, must use this rather than PodUnschedulable: the condition's +// LastTransitionTime is the only record of when the scheduler gave its verdict, and Message carries the +// per-node detail ("0/8 nodes are available: 2 Insufficient hugepages-2Mi") that no other field holds. +func PodUnschedulableCondition(pod *v1.Pod) *v1.PodCondition { + for i := range pod.Status.Conditions { + c := &pod.Status.Conditions[i] + if c.Type == v1.PodScheduled && c.Status == v1.ConditionFalse && c.Reason == "Unschedulable" { + return c + } + } + return nil +} + +// PodUnschedulable reports whether pod has a PodScheduled condition explicitly reporting +// Reason == "Unschedulable" (as opposed to merely lacking Status.NodeName, which is also true of a pod +// that simply hasn't been evaluated by the scheduler yet). +func PodUnschedulable(pod *v1.Pod) bool { + return PodUnschedulableCondition(pod) != nil +} diff --git a/internal/controllers/utils/pod_status_test.go b/internal/controllers/utils/pod_status_test.go new file mode 100644 index 000000000..a538e4bfe --- /dev/null +++ b/internal/controllers/utils/pod_status_test.go @@ -0,0 +1,50 @@ +package utils + +import ( + "testing" + + v1 "k8s.io/api/core/v1" +) + +func TestPodUnschedulable(t *testing.T) { + tests := []struct { + name string + pod *v1.Pod + want bool + }{ + { + name: "explicit Unschedulable condition", + pod: &v1.Pod{Status: v1.PodStatus{Conditions: []v1.PodCondition{ + {Type: v1.PodScheduled, Status: v1.ConditionFalse, Reason: "Unschedulable"}, + }}}, + want: true, + }, + { + name: "not yet evaluated by the scheduler is not the same as unschedulable", + pod: &v1.Pod{Status: v1.PodStatus{Conditions: []v1.PodCondition{ + {Type: v1.PodScheduled, Status: v1.ConditionFalse, Reason: "SchedulingGated"}, + }}}, + want: false, + }, + { + name: "PodScheduled true", + pod: &v1.Pod{Status: v1.PodStatus{Conditions: []v1.PodCondition{ + {Type: v1.PodScheduled, Status: v1.ConditionTrue}, + }}}, + want: false, + }, + { + name: "no conditions", + pod: &v1.Pod{}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := PodUnschedulable(tt.pod); got != tt.want { + t.Errorf("PodUnschedulable() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/pkg/domain/drives.go b/internal/pkg/domain/drives.go index 8f88695aa..e774fb9f4 100644 --- a/internal/pkg/domain/drives.go +++ b/internal/pkg/domain/drives.go @@ -3,6 +3,7 @@ package domain import ( "encoding/json" "fmt" + "sort" corev1 "k8s.io/api/core/v1" @@ -10,6 +11,7 @@ import ( ) // DriveEntry represents a drive in the weka.io/weka-drives annotation (non-proxy mode). +// TLC drives only: there is deliberately no Type field, because full-drives mode has no QLC. type DriveEntry struct { Serial string `json:"serial"` CapacityGiB int `json:"capacity_gib"` @@ -24,9 +26,23 @@ func DriveEntrySerials(entries []DriveEntry) []string { return serials } -// ReadDriveAnnotations reads drive entries from the weka.io/weka-full-drives annotation only. -// Returns nil (not an error) when the annotation is absent. -// Never returns entries with zero capacity — entries without capacity are filtered out. +// SortDriveEntriesDesc returns a capacity-descending copy of entries (never mutates the input), serial +// ascending as a deterministic tiebreak so equal-capacity drives always land in the same order. This is +// what makes a numDrives pin take a node's LARGEST drives, matching capacityplanner.SortDriveCapacitiesDesc. +func SortDriveEntriesDesc(entries []DriveEntry) []DriveEntry { + out := make([]DriveEntry, len(entries)) + copy(out, entries) + sort.Slice(out, func(i, j int) bool { + if out[i].CapacityGiB != out[j].CapacityGiB { + return out[i].CapacityGiB > out[j].CapacityGiB + } + return out[i].Serial < out[j].Serial + }) + return out +} + +// ReadDriveAnnotations reads drive entries from the weka.io/weka-full-drives annotation, filtering out +// zero-capacity entries. Returns nil (not an error) when the annotation is absent. func ReadDriveAnnotations(fullAnnotation string) ([]DriveEntry, error) { if fullAnnotation == "" { return nil, nil @@ -35,7 +51,6 @@ func ReadDriveAnnotations(fullAnnotation string) ([]DriveEntry, error) { if err := json.Unmarshal([]byte(fullAnnotation), &entries); err != nil { return nil, fmt.Errorf("failed to parse weka-full-drives: %w", err) } - // Filter out any zero-capacity entries — they must never be used for allocation or capacity calculation. result := make([]DriveEntry, 0, len(entries)) for _, e := range entries { if e.CapacityGiB > 0 { @@ -45,10 +60,8 @@ func ReadDriveAnnotations(fullAnnotation string) ([]DriveEntry, error) { return result, nil } -// ReadAnnotatedDriveSerials returns all drive serial IDs found in either node annotation -// (weka-full-drives or legacy weka-drives), deduplicated. Used in contexts that need to -// know which drives are annotated on the node (e.g. sign-exclusion, block/unblock) -// without needing capacity info. +// ReadAnnotatedDriveSerials returns all drive serials from either node annotation (weka-full-drives or +// legacy weka-drives), deduplicated — for callers that need only serials (e.g. sign-exclusion, block/unblock). func ReadAnnotatedDriveSerials(fullAnnotation, legacyAnnotation string) ([]string, error) { seen := make(map[string]struct{}) serials := make([]string, 0) diff --git a/internal/pkg/domain/drives_test.go b/internal/pkg/domain/drives_test.go index c3371654c..b7f3dc4ee 100644 --- a/internal/pkg/domain/drives_test.go +++ b/internal/pkg/domain/drives_test.go @@ -266,3 +266,83 @@ func deepEqualStrings(a, b []string) bool { } return true } + +func TestSortDriveEntriesDesc(t *testing.T) { + tests := []struct { + name string + entries []DriveEntry + expected []DriveEntry + }{ + { + name: "empty slice", + entries: []DriveEntry{}, + expected: []DriveEntry{}, + }, + { + name: "already descending stays the same", + entries: []DriveEntry{ + {Serial: "SN001", CapacityGiB: 2048}, + {Serial: "SN002", CapacityGiB: 1024}, + }, + expected: []DriveEntry{ + {Serial: "SN001", CapacityGiB: 2048}, + {Serial: "SN002", CapacityGiB: 1024}, + }, + }, + { + name: "ascending input is reversed to descending", + entries: []DriveEntry{ + {Serial: "SN001", CapacityGiB: 512}, + {Serial: "SN002", CapacityGiB: 1024}, + {Serial: "SN003", CapacityGiB: 2048}, + }, + expected: []DriveEntry{ + {Serial: "SN003", CapacityGiB: 2048}, + {Serial: "SN002", CapacityGiB: 1024}, + {Serial: "SN001", CapacityGiB: 512}, + }, + }, + { + name: "arbitrary order sorts by capacity descending", + entries: []DriveEntry{ + {Serial: "medium", CapacityGiB: 300}, + {Serial: "largest", CapacityGiB: 500}, + {Serial: "smallest", CapacityGiB: 50}, + }, + expected: []DriveEntry{ + {Serial: "largest", CapacityGiB: 500}, + {Serial: "medium", CapacityGiB: 300}, + {Serial: "smallest", CapacityGiB: 50}, + }, + }, + { + name: "equal capacities break ties by serial ascending", + entries: []DriveEntry{ + {Serial: "C", CapacityGiB: 200}, + {Serial: "A", CapacityGiB: 200}, + {Serial: "B", CapacityGiB: 200}, + }, + expected: []DriveEntry{ + {Serial: "A", CapacityGiB: 200}, + {Serial: "B", CapacityGiB: 200}, + {Serial: "C", CapacityGiB: 200}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + original := make([]DriveEntry, len(tt.entries)) + copy(original, tt.entries) + + result := SortDriveEntriesDesc(tt.entries) + + if !deepEqualDriveEntries(result, tt.expected) { + t.Errorf("sorted entries mismatch\nexpected: %#v\ngot: %#v", tt.expected, result) + } + if !deepEqualDriveEntries(tt.entries, original) { + t.Errorf("input slice was mutated: before %#v, after %#v", original, tt.entries) + } + }) + } +} diff --git a/internal/pkg/domain/resources.go b/internal/pkg/domain/resources.go index 5926143ba..4347d5285 100644 --- a/internal/pkg/domain/resources.go +++ b/internal/pkg/domain/resources.go @@ -12,4 +12,7 @@ type DriveInfo struct { Partition string `json:"partition"` IsSigned bool `json:"is_signed"` // Means drive is signed by Weka WekaGuid string `json:"weka_guid,omitempty"` // Only populated if drive is signed + // Type is "TLC"/"QLC", derived from iu_size by the sign-tool; empty when the producer can't + // determine it (node-agent's kernel-only discovery). Wire-only, not persisted to an annotation. + Type string `json:"type,omitempty"` } diff --git a/internal/services/discovery/discovery.go b/internal/services/discovery/discovery.go index 65d81ebcb..ec5c09e90 100644 --- a/internal/services/discovery/discovery.go +++ b/internal/services/discovery/discovery.go @@ -121,9 +121,8 @@ type DiscoveryNodeInfo struct { // Node *corev1.Node `json:"-"` // this is not necessarily aligned with a node } -// PodDiscoverySnapshot holds the DiscoveryNodeInfo fields that affect pod spec creation. -// Stored as a pod annotation at creation time; compared against the actual scheduled -// node's info on subsequent reconciles to detect node-info mismatch. +// PodDiscoverySnapshot holds the DiscoveryNodeInfo fields that affect pod spec creation. Stored as a pod +// annotation at creation time and compared against the actual node on later reconciles to detect mismatch. type PodDiscoverySnapshot struct { IsHt bool `json:"is_ht"` Os string `json:"os,omitempty"` @@ -148,9 +147,8 @@ func (nodeInfo *DiscoveryNodeInfo) IsRhCos() bool { return nodeInfo.Os == weka.OsNameOpenshift } -// NodeInfoFromAnnotation parses a node's weka.io/discovery.json annotation into a DiscoveryNodeInfo. -// ok is false (and info nil) when the annotation is absent or unparsable. Single parse helper for the -// several call sites that read node discovery info off the annotation. +// NodeInfoFromAnnotation parses a node's weka.io/discovery.json annotation into a DiscoveryNodeInfo; ok is +// false (info nil) when the annotation is absent or unparsable. func NodeInfoFromAnnotation(node *corev1.Node) (info *DiscoveryNodeInfo, ok bool) { annotation, present := node.Annotations[DiscoveryAnnotation] if !present { @@ -159,10 +157,9 @@ func NodeInfoFromAnnotation(node *corev1.Node) (info *DiscoveryNodeInfo, ok bool return ParseNodeInfo(annotation) } -// ParseNodeInfo unmarshals a weka.io/discovery.json annotation value into a DiscoveryNodeInfo. ok is -// false (info nil) when the value is unparsable. Callers that already hold the annotation string (e.g. to -// distinguish "absent" from "present but unparsable") use this directly so the node's annotation map is -// read only once. +// ParseNodeInfo unmarshals a weka.io/discovery.json annotation value into a DiscoveryNodeInfo; ok is false +// (info nil) when unparsable. Used directly by callers that already hold the string, e.g. to distinguish +// "absent" from "unparsable". func ParseNodeInfo(annotation string) (info *DiscoveryNodeInfo, ok bool) { info = &DiscoveryNodeInfo{} if json.Unmarshal([]byte(annotation), info) != nil { @@ -171,9 +168,8 @@ func ParseNodeInfo(annotation string) (info *DiscoveryNodeInfo, ok bool) { return info, true } -// AnyNodeHasSelinux returns true if any node in the list is discovered to be an -// RHCOS/OpenShift node (which enforces SELinux by default). Nodes with a missing -// or unparsable discovery annotation are skipped. +// AnyNodeHasSelinux reports whether any node is RHCOS/OpenShift (which enforces SELinux by default); +// nodes with a missing/unparsable discovery annotation are skipped. func AnyNodeHasSelinux(nodes []corev1.Node) bool { for i := range nodes { if info, ok := NodeInfoFromAnnotation(&nodes[i]); ok && info.IsRhCos() { @@ -213,17 +209,14 @@ func (d *DiscoveryNodeInfo) GetContainerSharedDataPath(uid types.UID) string { return fmt.Sprintf("%s/containers/%s", d.GetHostsideSharedData(), uid) } -// GetHostsideEphemeralShare returns the host-side, node-level directory under -// /run (ephemeral — cleared on reboot) that is shared across all pods and -// clusters on this node. It is the generic parent for node-scoped ephemeral -// state; not tied to persistent storage or to any single cluster. +// GetHostsideEphemeralShare returns the host-side, node-level ephemeral directory under /run (cleared on +// reboot), shared across all pods/clusters on this node — the generic parent for node-scoped ephemeral state. func (d *DiscoveryNodeInfo) GetHostsideEphemeralShare() string { return "/run/weka/ephemeral" } -// GetHostsideSharedNetnsPath returns the host-side netns directory under the -// node ephemeral share. Shared across all pods and clusters on this node so -// network namespaces created on the host propagate to weka containers and back. +// GetHostsideSharedNetnsPath returns the host-side netns directory under the node ephemeral share, shared +// across pods/clusters so host-created network namespaces propagate to weka containers and back. func (d *DiscoveryNodeInfo) GetHostsideSharedNetnsPath() string { return d.GetHostsideEphemeralShare() + "/shared-netns" } @@ -238,27 +231,22 @@ type Discoverer interface { // IsContainerOperational checks if a container is operational and ready for operations func IsContainerOperational(container *weka.WekaContainer) bool { - // Container must have a cluster container ID assigned if container.Status.ClusterContainerID == nil { return false } - // Container must be in READY internal status if container.Status.InternalStatus != "READY" { return false } - // Container must have at least one management IP if len(container.Status.GetManagementIps()) == 0 { return false } - // Container must have WekaPort allocated if container.Status.Allocations == nil || container.Status.Allocations.WekaPort == 0 { return false } - // Container must not be in unsuitable statuses notSuitableStatuses := []weka.ContainerStatus{ weka.PodNotRunning, weka.Stopped, @@ -276,7 +264,6 @@ func SelectOperationalContainers(containers []*weka.WekaContainer, numContainers util.Shuffle(containers) for _, container := range containers { - // if roles are set - select only suitable roles if len(roles) == 0 { roles = []string{weka.WekaContainerModeDrive, weka.WekaContainerModeCompute} } @@ -287,7 +274,6 @@ func SelectOperationalContainers(containers []*weka.WekaContainer, numContainers } } - // Use common validation function if !IsContainerOperational(container) { continue } @@ -304,7 +290,7 @@ func SelectOperationalContainers(containers []*weka.WekaContainer, numContainers // if we selected at least one "Running" - lets go with it, if none - populate with many "not running" if len(selected) == 0 { - // if we could not select target amount of containers, we will select some random that are not running + // if we could not select target amount of containers, we will select some random that are not running util.Shuffle(containers) notSuitableStatuses := []weka.ContainerStatus{ @@ -383,8 +369,7 @@ func GetClusterNfsTargetIps(ctx context.Context, containers []*weka.WekaContaine return nfsTargetIps } -// Returns a map of FD to join IP port pairs -// (if FD label is not provided, FD will be empty string) +// SelectJoinIps returns a map of FD to join IP:port pairs; FD is "" when no FD label is set. func SelectJoinIps(containers []*weka.WekaContainer) (map[string][]string, error) { joinIpsByFD := make(map[string][]string) @@ -439,13 +424,10 @@ func GetClusterContainers(ctx context.Context, c client.Reader, cluster *weka.We return GetClusterContainersByClusterUID(ctx, c, string(cluster.UID), cluster.Namespace, mode) } -// GetClusterContainersNoFieldIndex is like GetClusterContainers but does NOT use the -// metadata.ownerReferences.uid field index. It lists the namespace and filters by owner UID in -// memory, so it works with a cache-less/direct client that has no field indexer registered (e.g. the -// weka-capacity CLI, which builds a plain client.New without a cache). The controller keeps using the -// index-based GetClusterContainers; the index is registered on the manager cache in -// setupContainerIndexes (cmd/manager/main.go). Sending that field selector through a direct client -// makes the apiserver reject it ("field label not supported: metadata.ownerReferences.uid"). +// GetClusterContainersNoFieldIndex is GetClusterContainers without the metadata.ownerReferences.uid field +// index: it filters by owner UID in memory instead, for clients with no field indexer registered (e.g. the +// weka-capacity CLI's direct client — the apiserver rejects that field selector without one). The +// controller's cache has the index registered via setupContainerIndexes (cmd/manager/main.go). func GetClusterContainersNoFieldIndex(ctx context.Context, c client.Reader, cluster *weka.WekaCluster, mode string) ([]*weka.WekaContainer, error) { return getClusterContainersByClusterUID(ctx, c, string(cluster.UID), cluster.Namespace, mode, false) } @@ -454,10 +436,8 @@ func GetClusterContainersByClusterUID(ctx context.Context, c client.Reader, clus return getClusterContainersByClusterUID(ctx, c, clusterUID, clusterNamespace, mode, true) } -// getClusterContainersByClusterUID lists a cluster's WekaContainers. When useFieldIndex is true it -// filters via the metadata.ownerReferences.uid cache field index (fast, but requires the index to be -// registered on the client's cache). When false it lists the namespace and filters by owner UID in -// memory — for clients without that index registered. +// getClusterContainersByClusterUID lists a cluster's WekaContainers, filtering by owner UID either via the +// metadata.ownerReferences.uid cache field index (useFieldIndex, requires it registered) or in memory. func getClusterContainersByClusterUID(ctx context.Context, c client.Reader, clusterUID, clusterNamespace, mode string, useFieldIndex bool) ([]*weka.WekaContainer, error) { containersList := weka.WekaContainerList{} listOpts := []client.ListOption{ @@ -518,8 +498,7 @@ func GetClientContainers(ctx context.Context, c client.Client, wekaClient *weka. func SelectActiveContainer(containers []*weka.WekaContainer) *weka.WekaContainer { operational := SelectOperationalContainers(containers, 1, nil) if len(operational) == 0 { - // return any random container if no operational found - util.Shuffle(containers) + util.Shuffle(containers) // no operational container: fall back to a random one if len(containers) == 0 { return nil } @@ -603,10 +582,10 @@ func SelectNonDeletedWekaContainers(containers []*weka.WekaContainer) []*weka.We nonDeleted := make([]*weka.WekaContainer, 0, len(containers)) for _, container := range containers { if container.DeletionTimestamp != nil { - continue // skip deleted containers + continue } if slices.Contains([]weka.ContainerState{weka.ContainerStateDeleting, weka.ContainerStateDestroying}, container.Spec.State) { - continue // skip containers that are in deleting or destroying state + continue } nonDeleted = append(nonDeleted, container) } @@ -652,14 +631,12 @@ func GetSsdProxyOnNode(ctx context.Context, c client.Client, nodeName weka.NodeN ctx, logger := instrumentation.CreateLogSpan(ctx, "GetSsdProxyOnNode", "nodeName", nodeName) defer logger.End() - // Get the operator namespace where ssdproxy containers are deployed operatorNamespace, err := util.GetPodNamespace() if err != nil { return nil, fmt.Errorf("failed to get operator namespace: %w", err) } - // List all ssdproxy containers in the operator namespace - // Note: We don't filter by cluster because ssdproxy containers are shared across clusters on the same node + // No cluster filter: ssdproxy containers are shared across clusters on the same node. kubeService := kubernetes.NewKubeService(c) containers, err := kubeService.GetWekaContainersSimple(ctx, operatorNamespace, string(nodeName), map[string]string{ "weka.io/mode": weka.WekaContainerModeSSDProxy, diff --git a/pkg/weka-k8s-api b/pkg/weka-k8s-api index 10989f4f8..6abf60b56 160000 --- a/pkg/weka-k8s-api +++ b/pkg/weka-k8s-api @@ -1 +1 @@ -Subproject commit 10989f4f8d357b6a2bf8a76e36850fe57f5f3814 +Subproject commit 6abf60b565f17968af610a008581ee97e81d2ef2 diff --git a/scripts/gen-api-docs.go b/scripts/gen-api-docs.go index edc2abbdf..39cd304b3 100644 --- a/scripts/gen-api-docs.go +++ b/scripts/gen-api-docs.go @@ -15,8 +15,8 @@ import ( // ---- JSON schema structures ---- type Schema struct { - APIVersion string `json:"apiVersion"` - Resources map[string]*ResourceInfo `json:"resources"` + APIVersion string `json:"apiVersion"` + Resources map[string]*ResourceInfo `json:"resources"` Definitions map[string]*Definition `json:"definitions"` } @@ -313,8 +313,8 @@ func generateSchema(sourceDir string) *Schema { computeUsedBy(definitions, mainCRDs) return &Schema{ - APIVersion: "v1alpha1", - Resources: resources, + APIVersion: "v1alpha1", + Resources: resources, Definitions: definitions, } } @@ -447,7 +447,7 @@ func generateTypeSection(f *os.File, typeName string, def *Definition) { } if hasFields { - fmt.Fprintf(f, "| JSON Field | Type | Description |\n") //nolint:errcheck // best-effort doc generation; a write failure would already be surfaced by a later os.WriteFile/f.Close error, or is simply not actionable for this internal tool + fmt.Fprintf(f, "| JSON Field | Type | Description |\n") //nolint:errcheck // best-effort doc generation; a write failure would already be surfaced by a later os.WriteFile/f.Close error, or is simply not actionable for this internal tool fmt.Fprintf(f, "|------------|------|-------------|\n") //nolint:errcheck // best-effort doc generation; a write failure would already be surfaced by a later os.WriteFile/f.Close error, or is simply not actionable for this internal tool for _, field := range def.Fields {