Skip to content

fix(vi): validate VirtualImage storage class #852

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
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
115 changes: 97 additions & 18 deletions hooks/module_config_validator.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
#
# Copyright 2024 Flant JSC
# Copyright 2025 Flant JSC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
Expand All @@ -14,18 +14,20 @@
# See the License for the specific language governing permissions and
# limitations under the License.

from ipaddress import IPv4Address, IPv4Network, ip_address, ip_network
from typing import Callable

import common
from deckhouse import hook
from lib.hooks.hook import Hook
from ipaddress import ip_network,ip_address,IPv4Network,IPv4Address
import common


class ModuleConfigValidateHook(Hook):
KIND="ModuleConfig"
API_VERSION="deckhouse.io/v1alpha1"
KIND = "ModuleConfig"
API_VERSION = "deckhouse.io/v1alpha1"
SNAPSHOT_MODULE_CONFIG = "module-config"
SNAPSHOT_NODES = "nodes"
SNAPSHOT_STORAGE_PROFILES = "internalvirtualizationstorageprofiles"

def __init__(self, module_name: str):
self.module_name = module_name
Expand All @@ -42,13 +44,11 @@ def generate_config(self) -> dict:
"executeHookOnEvent": [],
"apiVersion": self.API_VERSION,
"kind": self.KIND,
"nameSelector": {
"matchNames": [self.module_name]
},
"nameSelector": {"matchNames": [self.module_name]},
"group": "main",
"jqFilter": '{"cidrs": .spec.settings.virtualMachineCIDRs}',
"jqFilter": '{"settings": .spec.settings}',
"queue": self.queue,
"keepFullObjectsInMemory": False
"keepFullObjectsInMemory": False,
},
{
"name": self.SNAPSHOT_NODES,
Expand All @@ -59,32 +59,82 @@ def generate_config(self) -> dict:
"group": "main",
"jqFilter": '{"addresses": .status.addresses}',
"queue": self.queue,
"keepFullObjectsInMemory": False
}
]
"keepFullObjectsInMemory": False,
},
{
"name": self.SNAPSHOT_STORAGE_PROFILES,
"executeHookOnSynchronization": True,
"executeHookOnEvent": [],
"apiVersion": "cdi.internal.virtualization.deckhouse.io/v1beta1",
"kind": "InternalVirtualizationStorageProfile",
"group": "main",
"jqFilter": '{"profiles": .}',
"queue": self.queue,
"keepFullObjectsInMemory": False,
},
],
}

def check_overlaps_cidrs(self, networks: list[IPv4Network]) -> None:
"""Check for overlapping CIDRs in a list of networks."""
for i, net1 in enumerate(networks):
for net2 in networks[i + 1:]:
for net2 in networks[i + 1 :]:
if net1.overlaps(net2):
raise ValueError(f"Overlapping CIDRs {net1} and {net2}")

def check_node_addresses_overlap(self, networks: list[IPv4Network], node_addresses: list[IPv4Address]) -> None:
def check_node_addresses_overlap(
self, networks: list[IPv4Network], node_addresses: list[IPv4Address]
) -> None:
"""Check if node addresses overlap with any subnet."""
for addr in node_addresses:
for net in networks:
if addr in net:
raise ValueError(f"Node address {addr} overlaps with subnet {net}")

def validate_virtual_images_storage_class(self, vi_settings: dict) -> None:
"""Check that the StorageClass's `PersistentVolumeMode` is not the `Filesystem`."""
for profile in vi_settings["storageProfiles"]:
name = profile.get("metadata", dict()).get("name", "")
if name != "" and name == vi_settings["defaultStorageClassName"]:
claim_property_sets = profile.get("status", dict()).get(
"claimPropertySets", list()
)
try:
claim_property_set = claim_property_sets[0]
if claim_property_set["volumeMode"] == "Filesystem":
raise ValueError(
f"a `StorageClass` with the `PersistentVolumeFilesystem` mode cannot be used for `VirtualImages` currently: {name}"
)
except (IndexError, KeyError):
raise ValueError(
f"failed to validate the `PersistentVolumeMode` of the `StorageProfile`: {name}"
)
for profile in vi_settings["storageProfiles"]:
name = profile.get("metadata", dict()).get("name", "")
allowed_storage_classes = vi_settings["allowedStorageClassSelector"]["matchNames"]
if name != "" and name in allowed_storage_classes:
claim_property_sets = profile.get("status", dict()).get(
"claimPropertySets", list()
)
try:
claim_property_set = claim_property_sets[0]
if claim_property_set["volumeMode"] == "Filesystem":
raise ValueError(
f"a `StorageClass` with the `PersistentVolumeFilesystem` mode cannot be used for `VirtualImages` currently: {name}"
)
except (IndexError, KeyError):
raise ValueError(
f"failed to validate the `PersistentVolumeMode` of the `StorageProfile`: {name}"
)

def reconcile(self) -> Callable[[hook.Context], None]:
def r(ctx: hook.Context):
cidrs: list[IPv4Network] = [
ip_network(c)
for c in ctx.snapshots.get(self.SNAPSHOT_MODULE_CONFIG, [{}])[0]
.get("filterResult", {})
.get("cidrs", [])
for c in ctx.snapshots.get(self.SNAPSHOT_MODULE_CONFIG, [dict()])[0]
.get("filterResult", dict())
.get("settings", dict())
.get("virtualMachineCIDRs", list())
]
self.check_overlaps_cidrs(cidrs)

Expand All @@ -96,6 +146,35 @@ def r(ctx: hook.Context):
]
self.check_node_addresses_overlap(cidrs, node_addresses)

vi_default_storage_class: str = (
ctx.snapshots.get(self.SNAPSHOT_MODULE_CONFIG, [dict()])[0]
.get("filterResult", dict())
.get("settings", dict())
.get("virtualImages", dict())
.get("defaultStorageClassName", "")
)
storage_profiles: list[dict] = [
profile.get("filterResult", dict()).get("profiles", dict())
for profile in ctx.snapshots.get(self.SNAPSHOT_STORAGE_PROFILES, list())
]
vi_allowedStorageClassSelector: list[str] = [
sc
for sc in ctx.snapshots.get(self.SNAPSHOT_MODULE_CONFIG, [dict()])[0]
.get("filterResult", dict())
.get("settings", dict())
.get("virtualImages", dict())
.get("allowedStorageClassSelector", dict())
.get("matchNames", list())
]
vi_settings: dict[str, any] = {
"defaultStorageClassName": vi_default_storage_class,
"allowedStorageClassSelector": {
"matchNames": vi_allowedStorageClassSelector
},
"storageProfiles": storage_profiles,
}
self.validate_virtual_images_storage_class(vi_settings)

return r


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ func NewModuleConfigValidator(client client.Client) *validator.Validator[*mcapi.

cidrs := newCIDRsValidator(client)
reduceCIDRs := newRemoveCIDRsValidator(client)
viStorageClasses := newViStorageClassValidator(client)

return validator.NewValidator[*mcapi.ModuleConfig](logger).
WithPredicate(&validator.Predicate[*mcapi.ModuleConfig]{
Expand All @@ -54,5 +55,5 @@ func NewModuleConfigValidator(client client.Client) *validator.Validator[*mcapi.
oldMC.GetGeneration() != newMC.GetGeneration()
},
}).
WithUpdateValidators(cidrs, reduceCIDRs)
WithUpdateValidators(cidrs, reduceCIDRs, viStorageClasses)
}
Original file line number Diff line number Diff line change
Expand Up @@ -91,3 +91,35 @@ func convertToStringSlice(input []interface{}) ([]string, error) {
}
return result, nil
}

type viStorageClassSettings struct {
DefaultStorageClassName string
AllowedStorageClassSelector AllowedStorageClassSelector
}

type AllowedStorageClassSelector struct {
MatchNames []string
}

func parseViStorageClass(settings mcapi.SettingsValues) *viStorageClassSettings {
viScSettings := &viStorageClassSettings{}
if virtualImages, ok := settings["virtualImages"].(map[string]interface{}); ok {
if defaultClass, ok := virtualImages["defaultStorageClassName"].(string); ok {
viScSettings.DefaultStorageClassName = defaultClass
}

if allowedSelector, ok := virtualImages["allowedStorageClassSelector"].(map[string]interface{}); ok {
if matchNames, ok := allowedSelector["matchNames"].([]interface{}); ok {
var matchNameStrings []string
for _, name := range matchNames {
if strName, ok := name.(string); ok {
matchNameStrings = append(matchNameStrings, strName)
}
}
viScSettings.AllowedStorageClassSelector.MatchNames = matchNameStrings
}
}
}

return viScSettings
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/*
Copyright 2025 Flant JSC

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 moduleconfig

import (
"context"
"fmt"

corev1 "k8s.io/api/core/v1"
cdiv1 "kubevirt.io/containerized-data-importer-api/pkg/apis/core/v1beta1"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"

mcapi "github.com/deckhouse/virtualization-controller/pkg/controller/moduleconfig/api"
)

type viStorageClassValidator struct {
client client.Client
}

func newViStorageClassValidator(client client.Client) *viStorageClassValidator {
return &viStorageClassValidator{
client: client,
}
}

func (v viStorageClassValidator) ValidateUpdate(ctx context.Context, _, newMC *mcapi.ModuleConfig) (admission.Warnings, error) {
warnings := make([]string, 0)

viScSettings := parseViStorageClass(newMC.Spec.Settings)
if viScSettings.DefaultStorageClassName != "" {
scWarnings, err := v.validateStorageClass(ctx, viScSettings.DefaultStorageClassName)
if err != nil {
return warnings, err
}
if len(scWarnings) != 0 {
warnings = append(warnings, scWarnings...)
}
}

if len(viScSettings.AllowedStorageClassSelector.MatchNames) != 0 {
for _, sc := range viScSettings.AllowedStorageClassSelector.MatchNames {
scWarnings, err := v.validateStorageClass(ctx, sc)
if err != nil {
return warnings, err
}
if len(scWarnings) != 0 {
warnings = append(warnings, scWarnings...)
}
}
}

return admission.Warnings{}, nil
}

func (v viStorageClassValidator) validateStorageClass(ctx context.Context, scName string) (admission.Warnings, error) {
scProfile := &cdiv1.StorageProfile{}
err := v.client.Get(ctx, client.ObjectKey{Name: scName}, scProfile, &client.GetOptions{})
if err != nil {
return admission.Warnings{}, fmt.Errorf("failed to obtain the `StorageProfile` %s: %w", scName, err)
}
if len(scProfile.Status.ClaimPropertySets) == 0 {
return admission.Warnings{}, fmt.Errorf("failed to validate the `PersistentVolumeMode` of the `StorageProfile`: %s", scName)
}
if *scProfile.Status.ClaimPropertySets[0].VolumeMode == corev1.PersistentVolumeFilesystem {
return admission.Warnings{}, fmt.Errorf("a `StorageClass` with the `PersistentVolumeFilesystem` mode cannot be used for `VirtualImages` currently: %s", scName)
}

return admission.Warnings{}, nil
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (

corev1 "k8s.io/api/core/v1"
storev1 "k8s.io/api/storage/v1"
cdiv1 "kubevirt.io/containerized-data-importer-api/pkg/apis/core/v1beta1"

"github.com/deckhouse/virtualization-controller/pkg/controller/supplements"
"github.com/deckhouse/virtualization-controller/pkg/controller/vi/internal/source"
Expand All @@ -37,5 +38,6 @@ type Sources interface {

type DiskService interface {
GetStorageClass(ctx context.Context, storageClassName *string) (*storev1.StorageClass, error)
GetStorageProfile(ctx context.Context, name string) (*cdiv1.StorageProfile, error)
GetPersistentVolumeClaim(ctx context.Context, sup *supplements.Generator) (*corev1.PersistentVolumeClaim, error)
}
Loading
Loading