Skip to content
Merged
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
30 changes: 30 additions & 0 deletions assets/active/metadata.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -763,6 +763,36 @@ assets:
reconcile_order: 3
conditions: *metrics-exporter-conditions

# SBR detection-mode config. Auto-detects sharedStorageClass from CDI StorageProfiles
# (picks alphabetically first RWX Filesystem class). Omits field when none found.
# Admin should set platform.kubevirt.io/sbr-storage-class on HCO if multiple RWX classes exist.
# Opt-in (tech preview): requires platform.kubevirt.io/enable-node-remediation annotation.
- name: sbr-detection-config
path: active/node-remediation/storage-based-remediation-config.yaml.tpl
gate_crd: storagebasedremediationconfigs.storage-based-remediation.medik8s.io
phase: 1
install: opt-in
component: StorageBasedRemediationConfig
reconcile_order: 1
conditions:
- type: annotation
key: platform.kubevirt.io/enable-node-remediation
value: "true"

# NHC with FAR remediator. User must create fenceagentsremediationtemplate-default
# in openshift-workload-availability with BMC credentials.
- name: nhc-far
path: active/node-remediation/nhc-far.yaml
gate_crd: nodehealthchecks.remediation.medik8s.io
phase: 1
install: opt-in
component: NodeHealthCheck
reconcile_order: 2
conditions:
- type: annotation
key: platform.kubevirt.io/enable-node-remediation
value: "true"

# Phase 2: Opt-in - Advanced features
# TODO: Implement these assets:
# - vfio-assign (machine-config/04-vfio-assign.yaml.tpl)
Expand Down
25 changes: 25 additions & 0 deletions assets/active/node-remediation/nhc-far.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
apiVersion: remediation.medik8s.io/v1alpha1
kind: NodeHealthCheck
metadata:
name: far
spec:
minHealthy: "51%"
remediationTemplate:
apiVersion: fence-agents-remediation.medik8s.io/v1alpha1
kind: FenceAgentsRemediationTemplate
name: fenceagentsremediationtemplate-default
namespace: openshift-workload-availability
selector:
matchExpressions:
- key: node-role.kubernetes.io/worker
operator: Exists
unhealthyConditions:
- duration: 300s
status: "False"
type: Ready
- duration: 300s
status: Unknown
type: Ready
- duration: 300s
status: "True"
type: SBRStorageUnhealthy
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{{- $sc := dig "metadata" "annotations" "platform.kubevirt.io/sbr-storage-class" "" .HCO.Object -}}
{{- if not $sc }}{{ $sc = storageProfileRWXClass }}{{ end -}}
apiVersion: storage-based-remediation.medik8s.io/v1alpha1
kind: StorageBasedRemediationConfig
metadata:
name: autopilot-recommended-values-detection-only
namespace: openshift-workload-availability
spec:
{{- if $sc }}
sharedStorageClass: {{ $sc }}
{{- end }}
detectOnlyMode: Enabled
3 changes: 3 additions & 0 deletions cmd/rbac-gen/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ func formatRulesWithComments(static, transitive, dynamic []rbac.Rule) string {
"CRD Discovery (for soft dependency detection and template introspection)",
"OpenShift Infrastructure CR (for topology detection: HCP, compact, cloud provider)",
"Namespaces (pre-apply guard: verify target namespace before consuming a rate-limit token)",
"CDI StorageProfiles (for RWX StorageClass auto-detection in templates)",
}
for i, rule := range static {
if i < len(staticComments) {
Expand Down Expand Up @@ -180,6 +181,8 @@ func commentForAPIGroup(group string) string {
return "Self Node Remediation"
case "fence-agents-remediation.medik8s.io":
return "Fence Agents Remediation"
case "storage-based-remediation.medik8s.io":
return "Storage Based Remediation"

@razo7 razo7 Aug 12, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The official name has a hyphen

Suggested change
return "Storage Based Remediation"
return "Storage-Based Remediation"

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.

@razo7 ack. #301

case "forklift.konveyor.io":
return "Migration Toolkit for Virtualization (MTV)"
case "metallb.io":
Expand Down
19 changes: 19 additions & 0 deletions config/rbac/role.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,13 @@ rules:
- namespaces
verbs:
- get
# CDI StorageProfiles (for RWX StorageClass auto-detection in templates)
- apiGroups:
- cdi.kubevirt.io
resources:
- storageprofiles
verbs:
- list
# ========================================
# Transitive RBAC (from managed ClusterRole/Role assets)
# ========================================
Expand Down Expand Up @@ -285,3 +292,15 @@ rules:
- patch
- update
- watch
# Storage Based Remediation
- apiGroups:
- storage-based-remediation.medik8s.io
resources:
- storagebasedremediationconfigs
verbs:
- create
- get
- list
- patch
- update
- watch
1 change: 1 addition & 0 deletions hack/update-crds.sh
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ declare -a CRD_METADATA=(
"Medik8s Remediation|NodeHealthCheck|medik8s/node-healthcheck-operator|main|config/crd/bases/remediation.medik8s.io_nodehealthchecks.yaml|remediation/nodehealthchecks.remediation.medik8s.io.yaml"
"Medik8s Remediation|Self Node Remediation|medik8s/self-node-remediation|main|config/crd/bases/self-node-remediation.medik8s.io_selfnoderemediations.yaml|remediation/selfnoderemediations.self-node-remediation.medik8s.io.yaml"
"Medik8s Remediation|Fence Agents Remediation|medik8s/fence-agents-remediation|main|config/crd/bases/fence-agents-remediation.medik8s.io_fenceagentsremediations.yaml|remediation/fenceagentsremediations.fence-agents-remediation.medik8s.io.yaml"
"Medik8s Remediation|Storage Based Remediation|medik8s/storage-based-remediation|main|config/crd/bases/storage-based-remediation.medik8s.io_storagebasedremediationconfigs.yaml|remediation/storagebasedremediationconfigs.storage-based-remediation.medik8s.io.yaml"

# Third-Party Operators
"Third-Party Operators|MTV (Forklift)|kubev2v/forklift|main|operator/config/crd/bases/forklift.konveyor.io_forkliftcontrollers.yaml|operators/forklift.konveyor.io_forkliftcontrollers.yaml"
Expand Down
9 changes: 7 additions & 2 deletions pkg/controller/platform_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -398,9 +398,14 @@ func TestIsManagedCRD(t *testing.T) {
expected: true,
},
{
name: "NodeHealthCheck is not managed (removed)",
name: "NodeHealthCheck is managed",
crdName: "nodehealthchecks.remediation.medik8s.io",
expected: false,
expected: true,
},
{
name: "StorageBasedRemediationConfig is managed",
crdName: "storagebasedremediationconfigs.storage-based-remediation.medik8s.io",
expected: true,
},
{
name: "ForkliftController is managed",
Expand Down
68 changes: 68 additions & 0 deletions pkg/engine/renderer.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,15 @@ import (
"compress/gzip"
"context"
"fmt"
"sort"
"text/template"

sprig "github.com/Masterminds/sprig/v3"
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
logf "sigs.k8s.io/controller-runtime/pkg/log"

embeddedassets "github.com/kubevirt/virt-platform-autopilot/assets"
"github.com/kubevirt/virt-platform-autopilot/pkg/assets"
Expand Down Expand Up @@ -248,6 +250,12 @@ func (r *Renderer) customFuncMap() template.FuncMap {
}
return buf.String(), nil
},

// storageProfileRWXClass returns a StorageClass that supports ReadWriteMany + Filesystem,
// detected via CDI StorageProfiles. Returns "" if none found, or alphabetically first
// if multiple exist. Override with platform.kubevirt.io/sbr-storage-class annotation.
// Usage: {{ storageProfileRWXClass }}
"storageProfileRWXClass": r.storageProfileRWXClassFunc(),
}
}

Expand Down Expand Up @@ -559,3 +567,63 @@ func readAsset(path string) (string, error) {
}
return string(data), nil
}

// storageProfileRWXClassFunc discovers RWX Filesystem StorageClasses via CDI StorageProfiles.
// Returns "" when none found, or the alphabetically first when multiple exist.
// When multiple RWX classes are available, the admin should set
// platform.kubevirt.io/sbr-storage-class on HCO to pick the correct one explicitly.
func (r *Renderer) storageProfileRWXClassFunc() func() string {
return func() string {
if r.client == nil {
return ""
}

profiles := &unstructured.UnstructuredList{}
Comment thread
geetikakay marked this conversation as resolved.
profiles.SetKind("StorageProfileList")
profiles.SetAPIVersion("cdi.kubevirt.io/v1beta1")

if err := r.client.List(context.Background(), profiles); err != nil {
logf.Log.WithName("storageProfileRWXClass").V(2).Info("could not list StorageProfiles, skipping auto-detection", "cause", err)
return ""
Comment thread
geetikakay marked this conversation as resolved.
}

var candidates []string
for _, profile := range profiles.Items {
if profileSupportsRWXFilesystem(profile.Object) {
candidates = append(candidates, profile.GetName())
}
}
if len(candidates) == 0 {
return ""
}
sort.Strings(candidates)
return candidates[0]
}
}

func profileSupportsRWXFilesystem(obj map[string]any) bool {
sets, found, err := unstructured.NestedSlice(obj, "status", "claimPropertySets")
if err != nil {
logf.Log.WithName("storageProfileRWXClass").V(2).Info("could not read claimPropertySets, skipping profile", "cause", err)
}
if !found {
return false
}
for _, set := range sets {
s, ok := set.(map[string]any)
if !ok {
continue
}
volumeMode, _, _ := unstructured.NestedString(s, "volumeMode")
if volumeMode != "Filesystem" {
continue
}
modes, _, _ := unstructured.NestedStringSlice(s, "accessModes")
for _, mode := range modes {
if mode == "ReadWriteMany" {
return true
}
}
}
return false
}
156 changes: 156 additions & 0 deletions pkg/engine/renderer_storage_profile_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
/*
Copyright 2026 The KubeVirt Authors.

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 engine

import (
"testing"

"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"sigs.k8s.io/controller-runtime/pkg/client/fake"

"github.com/kubevirt/virt-platform-autopilot/pkg/assets"
)

func TestStorageProfileRWXClass(t *testing.T) {
scheme := runtime.NewScheme()

t.Run("returns first RWX Filesystem StorageClass", func(t *testing.T) {
cephfsProfile := &unstructured.Unstructured{}
setStorageProfileGVK(cephfsProfile)
cephfsProfile.SetName("ocs-storagecluster-cephfs")
_ = unstructured.SetNestedSlice(cephfsProfile.Object, []any{
map[string]any{
"accessModes": []any{"ReadWriteOnce", "ReadWriteMany"},
"volumeMode": "Filesystem",
},
}, "status", "claimPropertySets")

rbdProfile := &unstructured.Unstructured{}
setStorageProfileGVK(rbdProfile)
rbdProfile.SetName("ocs-storagecluster-ceph-rbd")
_ = unstructured.SetNestedSlice(rbdProfile.Object, []any{
map[string]any{
"accessModes": []any{"ReadWriteOnce"},
"volumeMode": "Block",
},
}, "status", "claimPropertySets")

fakeClient := fake.NewClientBuilder().
WithScheme(scheme).
WithObjects(cephfsProfile, rbdProfile).
Build()

loader := assets.NewLoader()
renderer := NewRenderer(loader)
renderer.SetClient(fakeClient)

funcMap := renderer.customFuncMap()
fn := funcMap["storageProfileRWXClass"].(func() string)

result := fn()
if result != "ocs-storagecluster-cephfs" {
t.Errorf("expected ocs-storagecluster-cephfs, got %q", result)
}
})

t.Run("returns alphabetically first when multiple RWX classes exist", func(t *testing.T) {
nfsProfile := &unstructured.Unstructured{}
setStorageProfileGVK(nfsProfile)
nfsProfile.SetName("nfs-client")
_ = unstructured.SetNestedSlice(nfsProfile.Object, []any{
map[string]any{
"accessModes": []any{"ReadWriteMany"},
"volumeMode": "Filesystem",
},
}, "status", "claimPropertySets")

cephfsProfile := &unstructured.Unstructured{}
setStorageProfileGVK(cephfsProfile)
cephfsProfile.SetName("ocs-storagecluster-cephfs")
_ = unstructured.SetNestedSlice(cephfsProfile.Object, []any{
map[string]any{
"accessModes": []any{"ReadWriteMany"},
"volumeMode": "Filesystem",
},
}, "status", "claimPropertySets")

fakeClient := fake.NewClientBuilder().
WithScheme(scheme).
WithObjects(nfsProfile, cephfsProfile).
Build()

loader := assets.NewLoader()
renderer := NewRenderer(loader)
renderer.SetClient(fakeClient)

funcMap := renderer.customFuncMap()
fn := funcMap["storageProfileRWXClass"].(func() string)

result := fn()
if result != "nfs-client" {
t.Errorf("expected nfs-client (alphabetically first), got %q", result)
}
})

t.Run("returns empty string when no RWX Filesystem class exists", func(t *testing.T) {
rbdProfile := &unstructured.Unstructured{}
setStorageProfileGVK(rbdProfile)
rbdProfile.SetName("ocs-storagecluster-ceph-rbd")
_ = unstructured.SetNestedSlice(rbdProfile.Object, []any{
map[string]any{
"accessModes": []any{"ReadWriteOnce"},
"volumeMode": "Block",
},
}, "status", "claimPropertySets")

fakeClient := fake.NewClientBuilder().
WithScheme(scheme).
WithObjects(rbdProfile).
Build()

loader := assets.NewLoader()
renderer := NewRenderer(loader)
renderer.SetClient(fakeClient)

funcMap := renderer.customFuncMap()
fn := funcMap["storageProfileRWXClass"].(func() string)

result := fn()
if result != "" {
t.Errorf("expected empty string, got %q", result)
}
})

t.Run("returns empty string when no client is set", func(t *testing.T) {
loader := assets.NewLoader()
renderer := NewRenderer(loader)

funcMap := renderer.customFuncMap()
fn := funcMap["storageProfileRWXClass"].(func() string)

result := fn()
if result != "" {
t.Errorf("expected empty string, got %q", result)
}
})
}

func setStorageProfileGVK(obj *unstructured.Unstructured) {
obj.SetKind("StorageProfile")
obj.SetAPIVersion("cdi.kubevirt.io/v1beta1")
}
Loading