From 2f31b9e4cdf64a0a21aaf20ed50a35abc9bad115 Mon Sep 17 00:00:00 2001 From: Pavel Tishkov Date: Wed, 15 Oct 2025 12:58:29 +0300 Subject: [PATCH 1/2] fix(module): user may delete or edit vmclass/generic - Remove helm labels and annotations from vmclass/generic. - Create secret/module-state to track initial creation of vmclass/generic. - Do not react on delete or update of vmclass/generic. Signed-off-by: Pavel Tishkov --- .../virtualization-module-hooks/register.go | 4 +- .../pkg/hooks/create-generic-vmclass/hook.go | 167 +++++++++ .../hooks/create-generic-vmclass/hook_test.go | 201 +++++++++++ .../hook.go | 166 +++++++++ .../hook_test.go | 223 ++++++++++++ .../hook.go | 102 ------ .../pkg/hooks/update-module-state/hook.go | 156 +++++++++ .../hooks/update-module-state/hook_test.go | 322 ++++++++++++++++++ .../vmclasses-default.yaml | 34 -- 9 files changed, 1238 insertions(+), 137 deletions(-) create mode 100644 images/hooks/pkg/hooks/create-generic-vmclass/hook.go create mode 100644 images/hooks/pkg/hooks/create-generic-vmclass/hook_test.go create mode 100644 images/hooks/pkg/hooks/drop-helm-labels-from-generic-vmclass/hook.go create mode 100644 images/hooks/pkg/hooks/drop-helm-labels-from-generic-vmclass/hook_test.go delete mode 100644 images/hooks/pkg/hooks/prevent-default-vmclasses-deletion/hook.go create mode 100644 images/hooks/pkg/hooks/update-module-state/hook.go create mode 100644 images/hooks/pkg/hooks/update-module-state/hook_test.go delete mode 100644 templates/virtualization-controller/vmclasses-default.yaml diff --git a/images/hooks/cmd/virtualization-module-hooks/register.go b/images/hooks/cmd/virtualization-module-hooks/register.go index 39c8b95a04..5b80a97965 100644 --- a/images/hooks/cmd/virtualization-module-hooks/register.go +++ b/images/hooks/cmd/virtualization-module-hooks/register.go @@ -19,16 +19,18 @@ package main import ( _ "hooks/pkg/hooks/ca-discovery" _ "hooks/pkg/hooks/copy-custom-certificate" + _ "hooks/pkg/hooks/create-generic-vmclass" _ "hooks/pkg/hooks/discovery-clusterip-service-for-dvcr" _ "hooks/pkg/hooks/discovery-workload-nodes" + _ "hooks/pkg/hooks/drop-helm-labels-from-generic-vmclass" _ "hooks/pkg/hooks/drop-openshift-labels" _ "hooks/pkg/hooks/generate-secret-for-dvcr" _ "hooks/pkg/hooks/migrate-delete-renamed-validation-admission-policy" _ "hooks/pkg/hooks/migrate-virthandler-kvm-node-labels" - _ "hooks/pkg/hooks/prevent-default-vmclasses-deletion" _ "hooks/pkg/hooks/tls-certificates-api" _ "hooks/pkg/hooks/tls-certificates-api-proxy" _ "hooks/pkg/hooks/tls-certificates-controller" _ "hooks/pkg/hooks/tls-certificates-dvcr" + _ "hooks/pkg/hooks/update-module-state" _ "hooks/pkg/hooks/validate-module-config" ) diff --git a/images/hooks/pkg/hooks/create-generic-vmclass/hook.go b/images/hooks/pkg/hooks/create-generic-vmclass/hook.go new file mode 100644 index 0000000000..cf81f2388c --- /dev/null +++ b/images/hooks/pkg/hooks/create-generic-vmclass/hook.go @@ -0,0 +1,167 @@ +/* +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 create_generic_vmclass + +import ( + "context" + "fmt" + + "hooks/pkg/settings" + + "github.com/deckhouse/virtualization/api/core" + "github.com/deckhouse/virtualization/api/core/v1alpha2" + + "github.com/deckhouse/module-sdk/pkg" + "github.com/deckhouse/module-sdk/pkg/registry" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" +) + +const ( + moduleStateSecretSnapshot = "module-state-secret" + vmClassSnapshot = "vmclass-generic" + + moduleStateSecretName = "module-state" + genericVMClassName = "generic" + + apiVersion = core.GroupName + "/" + v1alpha2.Version +) + +var _ = registry.RegisterFunc(config, Reconcile) + +var config = &pkg.HookConfig{ + OnBeforeHelm: &pkg.OrderedConfig{Order: 5}, + Kubernetes: []pkg.KubernetesConfig{ + { + Name: moduleStateSecretSnapshot, + APIVersion: "v1", + Kind: "Secret", + JqFilter: `{"metadata": .metadata, "data": .data}`, + NameSelector: &pkg.NameSelector{ + MatchNames: []string{moduleStateSecretName}, + }, + NamespaceSelector: &pkg.NamespaceSelector{ + NameSelector: &pkg.NameSelector{ + MatchNames: []string{settings.ModuleNamespace}, + }, + }, + ExecuteHookOnSynchronization: ptr.To(false), + }, + { + Name: vmClassSnapshot, + APIVersion: apiVersion, + Kind: v1alpha2.VirtualMachineClassKind, + JqFilter: `.metadata.name`, + NameSelector: &pkg.NameSelector{ + MatchNames: []string{genericVMClassName}, + }, + LabelSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "app": "virtualization-controller", + "module": settings.ModuleName, + }, + }, + ExecuteHookOnSynchronization: ptr.To(false), + }, + }, + + Queue: fmt.Sprintf("modules/%s", settings.ModuleName), +} + +func Reconcile(_ context.Context, input *pkg.HookInput) error { + moduleStateSecrets := input.Snapshots.Get(moduleStateSecretSnapshot) + vmClasses := input.Snapshots.Get(vmClassSnapshot) + + // nothing to do if generic vmclass already exists + if len(vmClasses) > 0 { + return nil + } + + // if module-state secret exists and contains generic-vmclass-was-ever-created=true, nothing to do + if len(moduleStateSecrets) > 0 { + var moduleStateSecret corev1.Secret + if err := moduleStateSecrets[0].UnmarshalTo(&moduleStateSecret); err != nil { + return err + } + + if string(moduleStateSecret.Data["generic-vmclass-was-ever-created"]) == "true" { + return nil + } + } + + input.Logger.Info("Creating generic VirtualMachineClass") + + vmClass := &v1alpha2.VirtualMachineClass{ + TypeMeta: metav1.TypeMeta{ + APIVersion: apiVersion, + Kind: v1alpha2.VirtualMachineClassKind, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: genericVMClassName, + Labels: map[string]string{ + "app": "virtualization-controller", + "module": settings.ModuleName, + }, + }, + Spec: v1alpha2.VirtualMachineClassSpec{ + CPU: v1alpha2.CPU{ + Type: v1alpha2.CPUTypeModel, + Model: "Nehalem", + }, + SizingPolicies: []v1alpha2.SizingPolicy{ + { + Cores: &v1alpha2.SizingPolicyCores{ + Min: 1, + Max: 4, + }, + DedicatedCores: []bool{false}, + CoreFractions: []v1alpha2.CoreFractionValue{5, 10, 20, 50, 100}, + }, + { + Cores: &v1alpha2.SizingPolicyCores{ + Min: 5, + Max: 8, + }, + DedicatedCores: []bool{false}, + CoreFractions: []v1alpha2.CoreFractionValue{20, 50, 100}, + }, + { + Cores: &v1alpha2.SizingPolicyCores{ + Min: 9, + Max: 16, + }, + DedicatedCores: []bool{true, false}, + CoreFractions: []v1alpha2.CoreFractionValue{50, 100}, + }, + { + Cores: &v1alpha2.SizingPolicyCores{ + Min: 17, + Max: 1024, + }, + DedicatedCores: []bool{true, false}, + CoreFractions: []v1alpha2.CoreFractionValue{100}, + }, + }, + }, + } + + input.PatchCollector.Create(vmClass) + input.Logger.Info("VirtualMachineClass generic created") + + return nil +} diff --git a/images/hooks/pkg/hooks/create-generic-vmclass/hook_test.go b/images/hooks/pkg/hooks/create-generic-vmclass/hook_test.go new file mode 100644 index 0000000000..2178ab99f1 --- /dev/null +++ b/images/hooks/pkg/hooks/create-generic-vmclass/hook_test.go @@ -0,0 +1,201 @@ +/* +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 create_generic_vmclass + +import ( + "context" + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/deckhouse/deckhouse/pkg/log" + "github.com/deckhouse/module-sdk/pkg" + "github.com/deckhouse/module-sdk/testing/mock" + "github.com/deckhouse/virtualization/api/core/v1alpha2" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestCreateGenericVMClass(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Create Generic VMClass Suite") +} + +var _ = Describe("Create Generic VMClass hook", func() { + var ( + snapshots *mock.SnapshotsMock + patchCollector *mock.PatchCollectorMock + ) + + newInput := func() *pkg.HookInput { + return &pkg.HookInput{ + Snapshots: snapshots, + PatchCollector: patchCollector, + Logger: log.NewNop(), + } + } + + BeforeEach(func() { + snapshots = mock.NewSnapshotsMock(GinkgoT()) + patchCollector = mock.NewPatchCollectorMock(GinkgoT()) + }) + + AfterEach(func() { + snapshots = nil + patchCollector = nil + }) + + Context("when module-state secret exists with generic-vmclass-was-ever-created=true", func() { + BeforeEach(func() { + moduleStateSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "module-state", + Namespace: "d8-virtualization", + }, + Data: map[string][]byte{ + "generic-vmclass-was-ever-created": []byte("true"), + }, + } + + snapshots.GetMock.When(moduleStateSecretSnapshot).Then([]pkg.Snapshot{ + mock.NewSnapshotMock(GinkgoT()).UnmarshalToMock.Set(func(v any) error { + secret, ok := v.(*corev1.Secret) + Expect(ok).To(BeTrue()) + *secret = *moduleStateSecret + return nil + }), + }) + }) + + It("should not recreate generic vmclass when it doesn't exist but state says it was created (user may have deleted it intentionally)", func() { + snapshots.GetMock.When(vmClassSnapshot).Then([]pkg.Snapshot{}) + + patchCollector.CreateMock.Optional() + + Expect(Reconcile(context.Background(), newInput())).To(Succeed()) + Expect(patchCollector.CreateMock.Calls()).To(HaveLen(0)) + }) + + }) + + Context("when module-state secret doesn't exist", func() { + BeforeEach(func() { + snapshots.GetMock.When(moduleStateSecretSnapshot).Then([]pkg.Snapshot{}) + }) + + It("should create generic vmclass when it doesn't exist", func() { + snapshots.GetMock.When(vmClassSnapshot).Then([]pkg.Snapshot{}) + + patchCollector.CreateMock.Set(func(obj interface{}) { + vmClass, ok := obj.(*v1alpha2.VirtualMachineClass) + Expect(ok).To(BeTrue()) + Expect(vmClass.Name).To(Equal("generic")) + Expect(vmClass.Labels).To(Equal(map[string]string{ + "app": "virtualization-controller", + "module": "virtualization", + })) + }) + + Expect(Reconcile(context.Background(), newInput())).To(Succeed()) + Expect(patchCollector.CreateMock.Calls()).To(HaveLen(1)) + }) + + }) + + Context("when module-state secret exists but doesn't contain generic-vmclass-was-ever-created", func() { + BeforeEach(func() { + moduleStateSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "module-state", + Namespace: "d8-virtualization", + }, + Data: map[string][]byte{ + "other-key": []byte("other-value"), + }, + } + + snapshots.GetMock.When(moduleStateSecretSnapshot).Then([]pkg.Snapshot{ + mock.NewSnapshotMock(GinkgoT()).UnmarshalToMock.Set(func(v any) error { + secret, ok := v.(*corev1.Secret) + Expect(ok).To(BeTrue()) + *secret = *moduleStateSecret + return nil + }), + }) + }) + + It("should create generic vmclass when it doesn't exist", func() { + snapshots.GetMock.When(vmClassSnapshot).Then([]pkg.Snapshot{}) + + patchCollector.CreateMock.Set(func(obj interface{}) { + vmClass, ok := obj.(*v1alpha2.VirtualMachineClass) + Expect(ok).To(BeTrue()) + Expect(vmClass.Name).To(Equal("generic")) + Expect(vmClass.Labels).To(Equal(map[string]string{ + "app": "virtualization-controller", + "module": "virtualization", + })) + }) + + Expect(Reconcile(context.Background(), newInput())).To(Succeed()) + Expect(patchCollector.CreateMock.Calls()).To(HaveLen(1)) + }) + + }) + + Context("when module-state secret exists with generic-vmclass-was-ever-created=false", func() { + BeforeEach(func() { + moduleStateSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "module-state", + Namespace: "d8-virtualization", + }, + Data: map[string][]byte{ + "generic-vmclass-was-ever-created": []byte("false"), + }, + } + + snapshots.GetMock.When(moduleStateSecretSnapshot).Then([]pkg.Snapshot{ + mock.NewSnapshotMock(GinkgoT()).UnmarshalToMock.Set(func(v any) error { + secret, ok := v.(*corev1.Secret) + Expect(ok).To(BeTrue()) + *secret = *moduleStateSecret + return nil + }), + }) + }) + + It("should create generic vmclass when it doesn't exist", func() { + snapshots.GetMock.When(vmClassSnapshot).Then([]pkg.Snapshot{}) + + patchCollector.CreateMock.Set(func(obj interface{}) { + vmClass, ok := obj.(*v1alpha2.VirtualMachineClass) + Expect(ok).To(BeTrue()) + Expect(vmClass.Name).To(Equal("generic")) + Expect(vmClass.Labels).To(Equal(map[string]string{ + "app": "virtualization-controller", + "module": "virtualization", + })) + }) + + Expect(Reconcile(context.Background(), newInput())).To(Succeed()) + Expect(patchCollector.CreateMock.Calls()).To(HaveLen(1)) + }) + + }) +}) diff --git a/images/hooks/pkg/hooks/drop-helm-labels-from-generic-vmclass/hook.go b/images/hooks/pkg/hooks/drop-helm-labels-from-generic-vmclass/hook.go new file mode 100644 index 0000000000..23185bd5b2 --- /dev/null +++ b/images/hooks/pkg/hooks/drop-helm-labels-from-generic-vmclass/hook.go @@ -0,0 +1,166 @@ +/* +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 drop_helm_labels_from_generic_vmclass + +import ( + "context" + "fmt" + "strings" + + "github.com/deckhouse/module-sdk/pkg" + "github.com/deckhouse/module-sdk/pkg/registry" + "github.com/deckhouse/virtualization/api/core/v1alpha2" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" + + "hooks/pkg/settings" +) + +const ( + vmClassSnapshot = "vmclass-generic" + genericVMClassName = "generic" +) + +const ( + helmManagedByLabel = "app.kubernetes.io/managed-by" + helmHeritageLabel = "heritage" + helmReleaseNameAnno = "meta.helm.sh/release-name" + helmReleaseNamespaceAnno = "meta.helm.sh/release-namespace" +) + +var _ = registry.RegisterFunc(configDropHelmLabels, handlerDropHelmLabels) + +var configDropHelmLabels = &pkg.HookConfig{ + OnAfterHelm: &pkg.OrderedConfig{Order: 20}, + Kubernetes: []pkg.KubernetesConfig{ + { + Name: vmClassSnapshot, + APIVersion: "virtualization.deckhouse.io/v1alpha2", + Kind: v1alpha2.VirtualMachineClassKind, + JqFilter: "{name: .metadata.name, labels: .metadata.labels, annotations: .metadata.annotations}", + NameSelector: &pkg.NameSelector{ + MatchNames: []string{genericVMClassName}, + }, + LabelSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "app": "virtualization-controller", + "app.kubernetes.io/managed-by": "Helm", + "heritage": "deckhouse", + "module": settings.ModuleName, + }, + }, + ExecuteHookOnEvents: ptr.To(false), + }, + }, + + Queue: fmt.Sprintf("modules/%s", settings.ModuleName), +} + +type VMClassMetadata struct { + Name string `json:"name"` + Labels map[string]string `json:"labels"` + Annotations map[string]string `json:"annotations"` +} + +func handlerDropHelmLabels(_ context.Context, input *pkg.HookInput) error { + snaps := input.Snapshots.Get(vmClassSnapshot) + if len(snaps) == 0 { + return nil + } + + vmClass := &VMClassMetadata{} + err := snaps[0].UnmarshalTo(vmClass) + if err != nil { + input.Logger.Error("failed to unmarshal VMClass", "error", err) + return err + } + + if vmClass.Labels == nil { + return nil + } + + // Check if VMClass has all required labels to be processed + if vmClass.Labels["app"] != "virtualization-controller" || + vmClass.Labels["module"] != settings.ModuleName || + vmClass.Labels[helmManagedByLabel] != "Helm" || + vmClass.Labels[helmHeritageLabel] != "deckhouse" { + input.Logger.Debug("VMClass doesn't match required labels, skipping") + return nil + } + + var patches []map[string]interface{} + hasChanges := false + + // Check and prepare patches for Helm labels + if _, exists := vmClass.Labels[helmManagedByLabel]; exists { + patches = append(patches, map[string]interface{}{ + "op": "remove", + "path": fmt.Sprintf("/metadata/labels/%s", jsonPatchEscape(helmManagedByLabel)), + "value": nil, + }) + hasChanges = true + } + + if _, exists := vmClass.Labels[helmHeritageLabel]; exists { + patches = append(patches, map[string]interface{}{ + "op": "remove", + "path": fmt.Sprintf("/metadata/labels/%s", jsonPatchEscape(helmHeritageLabel)), + "value": nil, + }) + hasChanges = true + } + + // Check and prepare patches for Helm annotations + if vmClass.Annotations != nil { + if releaseName, exists := vmClass.Annotations[helmReleaseNameAnno]; exists && releaseName == settings.ModuleName { + patches = append(patches, map[string]interface{}{ + "op": "remove", + "path": fmt.Sprintf("/metadata/annotations/%s", jsonPatchEscape(helmReleaseNameAnno)), + "value": nil, + }) + hasChanges = true + } + + if releaseNamespace, exists := vmClass.Annotations[helmReleaseNamespaceAnno]; exists && releaseNamespace == settings.ModuleNamespace { + patches = append(patches, map[string]interface{}{ + "op": "remove", + "path": fmt.Sprintf("/metadata/annotations/%s", jsonPatchEscape(helmReleaseNamespaceAnno)), + "value": nil, + }) + hasChanges = true + } + } + + if !hasChanges { + return nil + } + + input.Logger.Info("Removing Helm labels and annotations from generic VMClass") + input.PatchCollector.PatchWithJSON( + patches, + "virtualization.deckhouse.io/v1alpha2", + "VirtualMachineClass", + "", + genericVMClassName, + ) + + return nil +} + +func jsonPatchEscape(s string) string { + return strings.NewReplacer("~", "~0", "/", "~1").Replace(s) +} diff --git a/images/hooks/pkg/hooks/drop-helm-labels-from-generic-vmclass/hook_test.go b/images/hooks/pkg/hooks/drop-helm-labels-from-generic-vmclass/hook_test.go new file mode 100644 index 0000000000..200be33583 --- /dev/null +++ b/images/hooks/pkg/hooks/drop-helm-labels-from-generic-vmclass/hook_test.go @@ -0,0 +1,223 @@ +/* +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 drop_helm_labels_from_generic_vmclass + +import ( + "context" + "fmt" + "testing" + + "github.com/deckhouse/deckhouse/pkg/log" + "github.com/deckhouse/module-sdk/pkg" + "github.com/deckhouse/module-sdk/testing/mock" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestDropHelmLabelsFromGenericVMClass(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Drop Helm labels from generic VMClass Suite") +} + +var _ = Describe("Drop Helm labels from generic VMClass", func() { + var ( + snapshots *mock.SnapshotsMock + patchCollector *mock.PatchCollectorMock + ) + + newInput := func() *pkg.HookInput { + return &pkg.HookInput{ + Snapshots: snapshots, + PatchCollector: patchCollector, + Logger: log.NewNop(), + } + } + + newSnapshot := func(withManagedBy, withHeritage bool, withAnnotations bool) pkg.Snapshot { + return mock.NewSnapshotMock(GinkgoT()).UnmarshalToMock.Set(func(v any) (err error) { + obj, ok := v.(*VMClassMetadata) + Expect(ok).To(BeTrue()) + obj.Name = genericVMClassName + obj.Labels = make(map[string]string) + + // Required labels for VMClass to be found by the hook + obj.Labels["app"] = "virtualization-controller" + obj.Labels["module"] = "virtualization" + + if withManagedBy { + obj.Labels[helmManagedByLabel] = "Helm" + } + if withHeritage { + obj.Labels[helmHeritageLabel] = "deckhouse" + } + + if withAnnotations { + obj.Annotations = make(map[string]string) + obj.Annotations[helmReleaseNameAnno] = "virtualization" + obj.Annotations[helmReleaseNamespaceAnno] = "d8-virtualization" + } + + return nil + }) + } + + setSnapshots := func(snaps ...pkg.Snapshot) { + snapshots.GetMock.When(vmClassSnapshot).Then(snaps) + } + + BeforeEach(func() { + snapshots = mock.NewSnapshotsMock(GinkgoT()) + patchCollector = mock.NewPatchCollectorMock(GinkgoT()) + }) + + It("Should drop both Helm labels and annotations from generic VMClass with all required labels", func() { + setSnapshots(newSnapshot(true, true, true)) + patchCollector.PatchWithJSONMock.Set(func(patch any, apiVersion, kind, namespace, name string, opts ...pkg.PatchCollectorOption) { + Expect(apiVersion).To(Equal("virtualization.deckhouse.io/v1alpha2")) + Expect(kind).To(Equal("VirtualMachineClass")) + Expect(namespace).To(Equal("")) + Expect(name).To(Equal(genericVMClassName)) + Expect(opts).To(HaveLen(0)) + + jsonPatch, ok := patch.([]map[string]interface{}) + Expect(ok).To(BeTrue()) + Expect(jsonPatch).To(HaveLen(4)) + + // Check first patch (managed-by label) + Expect(jsonPatch[0]["op"]).To(Equal("remove")) + Expect(jsonPatch[0]["path"]).To(Equal(fmt.Sprintf("/metadata/labels/%s", jsonPatchEscape(helmManagedByLabel)))) + Expect(jsonPatch[0]["value"]).To(BeNil()) + + // Check second patch (heritage label) + Expect(jsonPatch[1]["op"]).To(Equal("remove")) + Expect(jsonPatch[1]["path"]).To(Equal(fmt.Sprintf("/metadata/labels/%s", jsonPatchEscape(helmHeritageLabel)))) + Expect(jsonPatch[1]["value"]).To(BeNil()) + + // Check third patch (release-name annotation) + Expect(jsonPatch[2]["op"]).To(Equal("remove")) + Expect(jsonPatch[2]["path"]).To(Equal(fmt.Sprintf("/metadata/annotations/%s", jsonPatchEscape(helmReleaseNameAnno)))) + Expect(jsonPatch[2]["value"]).To(BeNil()) + + // Check fourth patch (release-namespace annotation) + Expect(jsonPatch[3]["op"]).To(Equal("remove")) + Expect(jsonPatch[3]["path"]).To(Equal(fmt.Sprintf("/metadata/annotations/%s", jsonPatchEscape(helmReleaseNamespaceAnno)))) + Expect(jsonPatch[3]["value"]).To(BeNil()) + }) + + Expect(handlerDropHelmLabels(context.Background(), newInput())).To(Succeed()) + }) + + It("Should do nothing when VMClass doesn't have all required labels", func() { + // Create a snapshot with VMClass that has only some required labels + partialLabelSnapshot := mock.NewSnapshotMock(GinkgoT()).UnmarshalToMock.Set(func(v any) (err error) { + obj, ok := v.(*VMClassMetadata) + Expect(ok).To(BeTrue()) + obj.Name = genericVMClassName + obj.Labels = make(map[string]string) + + // Only some required labels - VMClass won't be processed + obj.Labels["app"] = "virtualization-controller" + obj.Labels["module"] = "virtualization" + // Missing helmManagedByLabel and helmHeritageLabel + + return nil + }) + + setSnapshots(partialLabelSnapshot) + Expect(handlerDropHelmLabels(context.Background(), newInput())).To(Succeed()) + }) + + It("Should drop only labels when annotations are missing", func() { + setSnapshots(newSnapshot(true, true, false)) + patchCollector.PatchWithJSONMock.Set(func(patch any, apiVersion, kind, namespace, name string, opts ...pkg.PatchCollectorOption) { + jsonPatch, ok := patch.([]map[string]interface{}) + Expect(ok).To(BeTrue()) + Expect(jsonPatch).To(HaveLen(2)) + + // Check first patch (managed-by label) + Expect(jsonPatch[0]["op"]).To(Equal("remove")) + Expect(jsonPatch[0]["path"]).To(Equal(fmt.Sprintf("/metadata/labels/%s", jsonPatchEscape(helmManagedByLabel)))) + + // Check second patch (heritage label) + Expect(jsonPatch[1]["op"]).To(Equal("remove")) + Expect(jsonPatch[1]["path"]).To(Equal(fmt.Sprintf("/metadata/labels/%s", jsonPatchEscape(helmHeritageLabel)))) + }) + + Expect(handlerDropHelmLabels(context.Background(), newInput())).To(Succeed()) + }) + + It("Should not drop annotations with wrong values", func() { + wrongAnnotationSnapshot := mock.NewSnapshotMock(GinkgoT()).UnmarshalToMock.Set(func(v any) (err error) { + obj, ok := v.(*VMClassMetadata) + Expect(ok).To(BeTrue()) + obj.Name = genericVMClassName + obj.Labels = make(map[string]string) + + // Required labels for VMClass to be found by the hook + obj.Labels["app"] = "virtualization-controller" + obj.Labels["module"] = "virtualization" + obj.Labels[helmManagedByLabel] = "Helm" + obj.Labels[helmHeritageLabel] = "deckhouse" + + // Annotations with wrong values - should not be removed + obj.Annotations = make(map[string]string) + obj.Annotations[helmReleaseNameAnno] = "wrong-module-name" + obj.Annotations[helmReleaseNamespaceAnno] = "wrong-namespace" + + return nil + }) + + setSnapshots(wrongAnnotationSnapshot) + patchCollector.PatchWithJSONMock.Set(func(patch any, apiVersion, kind, namespace, name string, opts ...pkg.PatchCollectorOption) { + jsonPatch, ok := patch.([]map[string]interface{}) + Expect(ok).To(BeTrue()) + Expect(jsonPatch).To(HaveLen(2)) + + // Only labels should be removed, not annotations with wrong values + Expect(jsonPatch[0]["path"]).To(ContainSubstring("/metadata/labels/")) + Expect(jsonPatch[1]["path"]).To(ContainSubstring("/metadata/labels/")) + }) + + Expect(handlerDropHelmLabels(context.Background(), newInput())).To(Succeed()) + }) + + It("Should do nothing when VMClass not found", func() { + setSnapshots() + Expect(handlerDropHelmLabels(context.Background(), newInput())).To(Succeed()) + }) + + It("Should do nothing when VMClass exists but doesn't match label selector", func() { + // Create a snapshot with VMClass that has wrong labels + wrongLabelSnapshot := mock.NewSnapshotMock(GinkgoT()).UnmarshalToMock.Set(func(v any) (err error) { + obj, ok := v.(*VMClassMetadata) + Expect(ok).To(BeTrue()) + obj.Name = genericVMClassName + obj.Labels = make(map[string]string) + + // Wrong labels - VMClass won't be found by the hook + obj.Labels["app"] = "wrong-app" + obj.Labels["module"] = "wrong-module" + obj.Labels[helmManagedByLabel] = "Helm" + obj.Labels[helmHeritageLabel] = "deckhouse" + + return nil + }) + + setSnapshots(wrongLabelSnapshot) + Expect(handlerDropHelmLabels(context.Background(), newInput())).To(Succeed()) + }) +}) diff --git a/images/hooks/pkg/hooks/prevent-default-vmclasses-deletion/hook.go b/images/hooks/pkg/hooks/prevent-default-vmclasses-deletion/hook.go deleted file mode 100644 index a47b422f38..0000000000 --- a/images/hooks/pkg/hooks/prevent-default-vmclasses-deletion/hook.go +++ /dev/null @@ -1,102 +0,0 @@ -/* -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 prevent_default_vmclasses_deletion - -import ( - "context" - "fmt" - - "hooks/pkg/settings" - - "github.com/deckhouse/virtualization/api/core" - "github.com/deckhouse/virtualization/api/core/v1alpha2" - - "github.com/deckhouse/module-sdk/pkg" - "github.com/deckhouse/module-sdk/pkg/registry" - - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -const ( - removePassthroughHookName = "Prevent default VirtualMachineClasses deletion" - removePassthroughHookJQFilter = `.metadata` - // see https://helm.sh/docs/howto/charts_tips_and_tricks/#tell-helm-not-to-uninstall-a-resource - helmResourcePolicyKey = "helm.sh/resource-policy" - helmResourcePolicyKeep = "keep" - apiVersion = core.GroupName + "/" + v1alpha2.Version -) - -var _ = registry.RegisterFunc(config, Reconcile) - -var config = &pkg.HookConfig{ - OnBeforeHelm: &pkg.OrderedConfig{Order: 10}, - Kubernetes: []pkg.KubernetesConfig{ - { - Name: removePassthroughHookName, - APIVersion: apiVersion, - Kind: v1alpha2.VirtualMachineClassKind, - JqFilter: removePassthroughHookJQFilter, - - LabelSelector: &metav1.LabelSelector{ - MatchLabels: map[string]string{ - "module": settings.ModuleName, - }, - }, - }, - }, - - Queue: fmt.Sprintf("modules/%s", settings.ModuleName), -} - -func Reconcile(_ context.Context, input *pkg.HookInput) error { - vmcs := input.Snapshots.Get(removePassthroughHookName) - - if len(vmcs) == 0 { - input.Logger.Info("No VirtualMachineClasses found, nothing to do") - return nil - } - - for _, vmc := range vmcs { - metadata := &metav1.ObjectMeta{} - if err := vmc.UnmarshalTo(metadata); err != nil { - input.Logger.Error(fmt.Sprintf("Failed to unmarshal metadata VirtualMachineClasses %v", err)) - } - - policy := metadata.GetAnnotations()[helmResourcePolicyKey] - if policy == helmResourcePolicyKeep { - input.Logger.Info(fmt.Sprintf("VirtualMachineClass %s already has helm.sh/resource-policy=keep", metadata.Name)) - continue - } - - op := "add" - if policy != "" { - op = "replace" - input.Logger.Info(fmt.Sprintf("VirtualMachineClass %s has helm.sh/resource-policy=%s, will be replaced with helm.sh/resource-policy=keep", metadata.Name, policy)) - } - patch := []interface{}{ - map[string]string{ - "op": op, - "path": "/metadata/annotations/helm.sh~1resource-policy", - "value": helmResourcePolicyKeep, - }, - } - input.PatchCollector.JSONPatch(patch, apiVersion, v1alpha2.VirtualMachineClassKind, "", metadata.Name) - input.Logger.Info(fmt.Sprintf("Added helm.sh/resource-policy=keep to VirtualMachineClass %s", metadata.Name)) - } - - return nil -} diff --git a/images/hooks/pkg/hooks/update-module-state/hook.go b/images/hooks/pkg/hooks/update-module-state/hook.go new file mode 100644 index 0000000000..2de434d2db --- /dev/null +++ b/images/hooks/pkg/hooks/update-module-state/hook.go @@ -0,0 +1,156 @@ +/* +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 update_module_state + +import ( + "context" + "encoding/base64" + "fmt" + + "hooks/pkg/settings" + + "github.com/deckhouse/virtualization/api/core" + "github.com/deckhouse/virtualization/api/core/v1alpha2" + + "github.com/deckhouse/module-sdk/pkg" + "github.com/deckhouse/module-sdk/pkg/registry" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" +) + +const ( + updateModuleStateHookName = "Update module-state secret" + vmClassSnapshot = "vmclass-generic" + moduleStateSecretSnapshot = "module-state-secret" + + genericVMClassName = "generic" + moduleStateSecretName = "module-state" + + apiVersion = core.GroupName + "/" + v1alpha2.Version + + // State fields configuration + genericVMClassStateKey = "generic-vmclass-was-ever-created" +) + +var _ = registry.RegisterFunc(config, Reconcile) + +var config = &pkg.HookConfig{ + OnBeforeHelm: &pkg.OrderedConfig{Order: 15}, + Kubernetes: []pkg.KubernetesConfig{ + { + Name: vmClassSnapshot, + APIVersion: apiVersion, + Kind: v1alpha2.VirtualMachineClassKind, + JqFilter: `.metadata.name`, + NameSelector: &pkg.NameSelector{ + MatchNames: []string{genericVMClassName}, + }, + LabelSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "app": "virtualization-controller", + "module": settings.ModuleName, + }, + }, + ExecuteHookOnSynchronization: ptr.To(false), + }, + { + Name: moduleStateSecretSnapshot, + APIVersion: "v1", + Kind: "Secret", + JqFilter: `{"metadata": .metadata, "data": .data}`, + NameSelector: &pkg.NameSelector{ + MatchNames: []string{moduleStateSecretName}, + }, + NamespaceSelector: &pkg.NamespaceSelector{ + NameSelector: &pkg.NameSelector{ + MatchNames: []string{settings.ModuleNamespace}, + }, + }, + ExecuteHookOnSynchronization: ptr.To(false), + }, + }, + + Queue: fmt.Sprintf("modules/%s", settings.ModuleName), +} + +type ModuleState struct { + GenericVMClassCreated bool +} + +func (ms ModuleState) ToSecretData() map[string][]byte { + value := fmt.Sprintf("%t", ms.GenericVMClassCreated) + return map[string][]byte{ + genericVMClassStateKey: []byte(value), + } +} + +func (ms ModuleState) ToPatchData() map[string]interface{} { + value := fmt.Sprintf("%t", ms.GenericVMClassCreated) + return map[string]interface{}{ + "data": map[string]string{ + genericVMClassStateKey: base64.StdEncoding.EncodeToString([]byte(value)), + }, + } +} + +func Reconcile(_ context.Context, input *pkg.HookInput) error { + vmClasses := input.Snapshots.Get(vmClassSnapshot) + moduleStateSecrets := input.Snapshots.Get(moduleStateSecretSnapshot) + + vmClassExists := len(vmClasses) > 0 + + // Load existing state + currentState := ModuleState{GenericVMClassCreated: false} + if len(moduleStateSecrets) > 0 { + var moduleStateSecret corev1.Secret + if err := moduleStateSecrets[0].UnmarshalTo(&moduleStateSecret); err == nil { + if string(moduleStateSecret.Data[genericVMClassStateKey]) == "true" { + currentState.GenericVMClassCreated = true + } + } + } + + // Update state: generic-vmclass-was-ever-created can only transition from false to true + newState := ModuleState{ + GenericVMClassCreated: currentState.GenericVMClassCreated || vmClassExists, + } + + // Always ensure secret exists with current state + if len(moduleStateSecrets) > 0 { + input.PatchCollector.PatchWithMerge(newState.ToPatchData(), "v1", "Secret", settings.ModuleNamespace, moduleStateSecretName) + } else { + secret := &corev1.Secret{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "v1", + Kind: "Secret", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: moduleStateSecretName, + Namespace: settings.ModuleNamespace, + Labels: map[string]string{ + "module": settings.ModuleName, + }, + }, + Data: newState.ToSecretData(), + Type: "Opaque", + } + input.PatchCollector.Create(secret) + } + + return nil +} diff --git a/images/hooks/pkg/hooks/update-module-state/hook_test.go b/images/hooks/pkg/hooks/update-module-state/hook_test.go new file mode 100644 index 0000000000..23518b192c --- /dev/null +++ b/images/hooks/pkg/hooks/update-module-state/hook_test.go @@ -0,0 +1,322 @@ +/* +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 update_module_state + +import ( + "context" + "encoding/base64" + "testing" + + "hooks/pkg/settings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/deckhouse/deckhouse/pkg/log" + "github.com/deckhouse/module-sdk/pkg" + "github.com/deckhouse/module-sdk/testing/mock" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestUpdateModuleState(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Update Module State Suite") +} + +var _ = Describe("Update Module State hook", func() { + var ( + snapshots *mock.SnapshotsMock + patchCollector *mock.PatchCollectorMock + ) + + newInput := func() *pkg.HookInput { + return &pkg.HookInput{ + Snapshots: snapshots, + PatchCollector: patchCollector, + Logger: log.NewNop(), + } + } + + BeforeEach(func() { + snapshots = mock.NewSnapshotsMock(GinkgoT()) + patchCollector = mock.NewPatchCollectorMock(GinkgoT()) + }) + + AfterEach(func() { + snapshots = nil + patchCollector = nil + }) + + Context("when generic vmclass exists", func() { + BeforeEach(func() { + snapshots.GetMock.When(vmClassSnapshot).Then([]pkg.Snapshot{ + mock.NewSnapshotMock(GinkgoT()), + }) + }) + + It("should create module-state secret when it doesn't exist", func() { + snapshots.GetMock.When(moduleStateSecretSnapshot).Then([]pkg.Snapshot{}) + + patchCollector.CreateMock.Set(func(obj interface{}) { + secret, ok := obj.(*corev1.Secret) + Expect(ok).To(BeTrue()) + Expect(secret.Name).To(Equal("module-state")) + Expect(secret.Namespace).To(Equal(settings.ModuleNamespace)) + Expect(secret.Data).To(HaveKey("generic-vmclass-was-ever-created")) + + Expect(string(secret.Data["generic-vmclass-was-ever-created"])).To(Equal("true")) + }) + + patchCollector.PatchWithMergeMock.Optional() + + Expect(Reconcile(context.Background(), newInput())).To(Succeed()) + Expect(patchCollector.CreateMock.Calls()).To(HaveLen(1)) + Expect(patchCollector.PatchWithMergeMock.Calls()).To(HaveLen(0)) + }) + + It("should update module-state secret when it exists but has wrong value", func() { + moduleStateSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "module-state", + Namespace: settings.ModuleNamespace, + }, + Data: map[string][]byte{ + "generic-vmclass-was-ever-created": []byte("false"), + }, + } + + snapshots.GetMock.When(moduleStateSecretSnapshot).Then([]pkg.Snapshot{ + mock.NewSnapshotMock(GinkgoT()).UnmarshalToMock.Set(func(v any) error { + secret, ok := v.(*corev1.Secret) + Expect(ok).To(BeTrue()) + *secret = *moduleStateSecret + return nil + }), + }) + + patchCollector.PatchWithMergeMock.Set(func(obj interface{}, apiVersion, kind, namespace, name string, opts ...pkg.PatchCollectorOption) { + patchData, ok := obj.(map[string]interface{}) + Expect(ok).To(BeTrue()) + Expect(patchData).To(HaveKey("data")) + + data, ok := patchData["data"].(map[string]string) + Expect(ok).To(BeTrue()) + Expect(data).To(HaveKey("generic-vmclass-was-ever-created")) + + Expect(data["generic-vmclass-was-ever-created"]).To(Equal(base64.StdEncoding.EncodeToString([]byte("true")))) + }) + + patchCollector.CreateMock.Optional() + + Expect(Reconcile(context.Background(), newInput())).To(Succeed()) + Expect(patchCollector.PatchWithMergeMock.Calls()).To(HaveLen(1)) + Expect(patchCollector.CreateMock.Calls()).To(HaveLen(0)) + }) + + It("should update module-state secret even when it has correct value", func() { + moduleStateSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "module-state", + Namespace: settings.ModuleNamespace, + }, + Data: map[string][]byte{ + "generic-vmclass-was-ever-created": []byte("true"), + }, + } + + snapshots.GetMock.When(moduleStateSecretSnapshot).Then([]pkg.Snapshot{ + mock.NewSnapshotMock(GinkgoT()).UnmarshalToMock.Set(func(v any) error { + secret, ok := v.(*corev1.Secret) + Expect(ok).To(BeTrue()) + *secret = *moduleStateSecret + return nil + }), + }) + + patchCollector.PatchWithMergeMock.Set(func(obj interface{}, apiVersion, kind, namespace, name string, opts ...pkg.PatchCollectorOption) { + patchData, ok := obj.(map[string]interface{}) + Expect(ok).To(BeTrue()) + Expect(patchData).To(HaveKey("data")) + + data, ok := patchData["data"].(map[string]string) + Expect(ok).To(BeTrue()) + Expect(data).To(HaveKey("generic-vmclass-was-ever-created")) + + Expect(data["generic-vmclass-was-ever-created"]).To(Equal(base64.StdEncoding.EncodeToString([]byte("true")))) + }) + + patchCollector.CreateMock.Optional() + + Expect(Reconcile(context.Background(), newInput())).To(Succeed()) + Expect(patchCollector.PatchWithMergeMock.Calls()).To(HaveLen(1)) + Expect(patchCollector.CreateMock.Calls()).To(HaveLen(0)) + }) + }) + + Context("when generic vmclass doesn't exist", func() { + BeforeEach(func() { + snapshots.GetMock.When(vmClassSnapshot).Then([]pkg.Snapshot{}) + }) + + It("should create module-state secret even when vmclass doesn't exist and secret doesn't exist", func() { + snapshots.GetMock.When(moduleStateSecretSnapshot).Then([]pkg.Snapshot{}) + + patchCollector.CreateMock.Set(func(obj interface{}) { + secret, ok := obj.(*corev1.Secret) + Expect(ok).To(BeTrue()) + Expect(secret.Name).To(Equal("module-state")) + Expect(secret.Namespace).To(Equal(settings.ModuleNamespace)) + Expect(secret.Data).To(HaveKey("generic-vmclass-was-ever-created")) + + Expect(string(secret.Data["generic-vmclass-was-ever-created"])).To(Equal("false")) + }) + + patchCollector.PatchWithMergeMock.Optional() + + Expect(Reconcile(context.Background(), newInput())).To(Succeed()) + Expect(patchCollector.CreateMock.Calls()).To(HaveLen(1)) + Expect(patchCollector.PatchWithMergeMock.Calls()).To(HaveLen(0)) + }) + + It("should update module-state secret and keep historical record when vmclass doesn't exist but module-state indicates it was created", func() { + moduleStateSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "module-state", + Namespace: settings.ModuleNamespace, + }, + Data: map[string][]byte{ + "generic-vmclass-was-ever-created": []byte("true"), + }, + } + + snapshots.GetMock.When(moduleStateSecretSnapshot).Then([]pkg.Snapshot{ + mock.NewSnapshotMock(GinkgoT()).UnmarshalToMock.Set(func(v any) error { + secret, ok := v.(*corev1.Secret) + Expect(ok).To(BeTrue()) + *secret = *moduleStateSecret + return nil + }), + }) + + patchCollector.PatchWithMergeMock.Set(func(obj interface{}, apiVersion, kind, namespace, name string, opts ...pkg.PatchCollectorOption) { + patchData, ok := obj.(map[string]interface{}) + Expect(ok).To(BeTrue()) + Expect(patchData).To(HaveKey("data")) + + data, ok := patchData["data"].(map[string]string) + Expect(ok).To(BeTrue()) + Expect(data).To(HaveKey("generic-vmclass-created")) + + // Should keep historical record (true) even though VMClass doesn't exist now + Expect(data["generic-vmclass-created"]).To(Equal(base64.StdEncoding.EncodeToString([]byte("true")))) + }) + + patchCollector.CreateMock.Optional() + + Expect(Reconcile(context.Background(), newInput())).To(Succeed()) + Expect(patchCollector.PatchWithMergeMock.Calls()).To(HaveLen(1)) + Expect(patchCollector.CreateMock.Calls()).To(HaveLen(0)) + }) + + It("should update module-state secret when vmclass doesn't exist and secret contains false", func() { + moduleStateSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "module-state", + Namespace: settings.ModuleNamespace, + }, + Data: map[string][]byte{ + "generic-vmclass-was-ever-created": []byte("false"), + }, + } + + snapshots.GetMock.When(moduleStateSecretSnapshot).Then([]pkg.Snapshot{ + mock.NewSnapshotMock(GinkgoT()).UnmarshalToMock.Set(func(v any) error { + secret, ok := v.(*corev1.Secret) + Expect(ok).To(BeTrue()) + *secret = *moduleStateSecret + return nil + }), + }) + + patchCollector.PatchWithMergeMock.Set(func(obj interface{}, apiVersion, kind, namespace, name string, opts ...pkg.PatchCollectorOption) { + patchData, ok := obj.(map[string]interface{}) + Expect(ok).To(BeTrue()) + Expect(patchData).To(HaveKey("data")) + + data, ok := patchData["data"].(map[string]string) + Expect(ok).To(BeTrue()) + Expect(data).To(HaveKey("generic-vmclass-was-ever-created")) + + // Should remain false since VMClass doesn't exist + Expect(data["generic-vmclass-was-ever-created"]).To(Equal(base64.StdEncoding.EncodeToString([]byte("false")))) + }) + + patchCollector.CreateMock.Optional() + + Expect(Reconcile(context.Background(), newInput())).To(Succeed()) + Expect(patchCollector.PatchWithMergeMock.Calls()).To(HaveLen(1)) + Expect(patchCollector.CreateMock.Calls()).To(HaveLen(0)) + }) + }) + + Context("state transition logic", func() { + It("should preserve historical true value even when vmclass is deleted and recreated", func() { + // First, simulate that VMClass was created and state recorded as true + moduleStateSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "module-state", + Namespace: settings.ModuleNamespace, + }, + Data: map[string][]byte{ + "generic-vmclass-was-ever-created": []byte("true"), + }, + } + + // VMClass doesn't exist now (was deleted) + snapshots.GetMock.When(vmClassSnapshot).Then([]pkg.Snapshot{}) + + snapshots.GetMock.When(moduleStateSecretSnapshot).Then([]pkg.Snapshot{ + mock.NewSnapshotMock(GinkgoT()).UnmarshalToMock.Set(func(v any) error { + secret, ok := v.(*corev1.Secret) + Expect(ok).To(BeTrue()) + *secret = *moduleStateSecret + return nil + }), + }) + + patchCollector.PatchWithMergeMock.Set(func(obj interface{}, apiVersion, kind, namespace, name string, opts ...pkg.PatchCollectorOption) { + patchData, ok := obj.(map[string]interface{}) + Expect(ok).To(BeTrue()) + Expect(patchData).To(HaveKey("data")) + + data, ok := patchData["data"].(map[string]string) + Expect(ok).To(BeTrue()) + Expect(data).To(HaveKey("generic-vmclass-created")) + + // Should preserve historical true value even though VMClass doesn't exist + Expect(data["generic-vmclass-created"]).To(Equal(base64.StdEncoding.EncodeToString([]byte("true")))) + }) + + patchCollector.CreateMock.Optional() + + Expect(Reconcile(context.Background(), newInput())).To(Succeed()) + Expect(patchCollector.PatchWithMergeMock.Calls()).To(HaveLen(1)) + Expect(patchCollector.CreateMock.Calls()).To(HaveLen(0)) + }) + }) +}) diff --git a/templates/virtualization-controller/vmclasses-default.yaml b/templates/virtualization-controller/vmclasses-default.yaml deleted file mode 100644 index f2beb27764..0000000000 --- a/templates/virtualization-controller/vmclasses-default.yaml +++ /dev/null @@ -1,34 +0,0 @@ -apiVersion: virtualization.deckhouse.io/v1alpha2 -kind: VirtualMachineClass -metadata: - name: generic - {{- include "helm_lib_module_labels" (list . (dict "app" "virtualization-controller")) | nindent 2 }} -spec: - nodeSelector: - matchExpressions: - - key: node-role.kubernetes.io/control-plane - operator: DoesNotExist - cpu: - type: Model - model: Nehalem - sizingPolicies: - - cores: - min: 1 - max: 4 - dedicatedCores: [false] - coreFractions: [5, 10, 20, 50, 100] - - cores: - min: 5 - max: 8 - dedicatedCores: [false] - coreFractions: [20, 50, 100] - - cores: - min: 9 - max: 16 - dedicatedCores: [true, false] - coreFractions: [50, 100] - - cores: - min: 17 - max: 1024 - dedicatedCores: [true, false] - coreFractions: [100] From 54d112bb891360303b6e1a305ae395414b351b10 Mon Sep 17 00:00:00 2001 From: Ivan Mikheykin Date: Mon, 17 Nov 2025 11:47:42 +0300 Subject: [PATCH 2/2] - combine hooks into one, use template and values to create secret Signed-off-by: Ivan Mikheykin --- .../virtualization-module-hooks/register.go | 4 +- .../pkg/hooks/create-generic-vmclass/hook.go | 167 -------- .../hooks/create-generic-vmclass/hook_test.go | 201 --------- .../hook.go | 166 -------- .../hook_test.go | 223 ---------- .../pkg/hooks/install-vmclass-generic/hook.go | 326 +++++++++++++++ .../install-vmclass-generic/hook_test.go | 382 ++++++++++++++++++ .../pkg/hooks/update-module-state/hook.go | 156 ------- .../hooks/update-module-state/hook_test.go | 322 --------------- openapi/values.yaml | 4 + templates/module-state-secret.yaml | 14 + tools/kubeconform/fixtures/module-values.yaml | 1 + 12 files changed, 728 insertions(+), 1238 deletions(-) delete mode 100644 images/hooks/pkg/hooks/create-generic-vmclass/hook.go delete mode 100644 images/hooks/pkg/hooks/create-generic-vmclass/hook_test.go delete mode 100644 images/hooks/pkg/hooks/drop-helm-labels-from-generic-vmclass/hook.go delete mode 100644 images/hooks/pkg/hooks/drop-helm-labels-from-generic-vmclass/hook_test.go create mode 100644 images/hooks/pkg/hooks/install-vmclass-generic/hook.go create mode 100644 images/hooks/pkg/hooks/install-vmclass-generic/hook_test.go delete mode 100644 images/hooks/pkg/hooks/update-module-state/hook.go delete mode 100644 images/hooks/pkg/hooks/update-module-state/hook_test.go create mode 100644 templates/module-state-secret.yaml diff --git a/images/hooks/cmd/virtualization-module-hooks/register.go b/images/hooks/cmd/virtualization-module-hooks/register.go index 5b80a97965..425845d7ea 100644 --- a/images/hooks/cmd/virtualization-module-hooks/register.go +++ b/images/hooks/cmd/virtualization-module-hooks/register.go @@ -19,18 +19,16 @@ package main import ( _ "hooks/pkg/hooks/ca-discovery" _ "hooks/pkg/hooks/copy-custom-certificate" - _ "hooks/pkg/hooks/create-generic-vmclass" _ "hooks/pkg/hooks/discovery-clusterip-service-for-dvcr" _ "hooks/pkg/hooks/discovery-workload-nodes" - _ "hooks/pkg/hooks/drop-helm-labels-from-generic-vmclass" _ "hooks/pkg/hooks/drop-openshift-labels" _ "hooks/pkg/hooks/generate-secret-for-dvcr" + _ "hooks/pkg/hooks/install-vmclass-generic" _ "hooks/pkg/hooks/migrate-delete-renamed-validation-admission-policy" _ "hooks/pkg/hooks/migrate-virthandler-kvm-node-labels" _ "hooks/pkg/hooks/tls-certificates-api" _ "hooks/pkg/hooks/tls-certificates-api-proxy" _ "hooks/pkg/hooks/tls-certificates-controller" _ "hooks/pkg/hooks/tls-certificates-dvcr" - _ "hooks/pkg/hooks/update-module-state" _ "hooks/pkg/hooks/validate-module-config" ) diff --git a/images/hooks/pkg/hooks/create-generic-vmclass/hook.go b/images/hooks/pkg/hooks/create-generic-vmclass/hook.go deleted file mode 100644 index cf81f2388c..0000000000 --- a/images/hooks/pkg/hooks/create-generic-vmclass/hook.go +++ /dev/null @@ -1,167 +0,0 @@ -/* -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 create_generic_vmclass - -import ( - "context" - "fmt" - - "hooks/pkg/settings" - - "github.com/deckhouse/virtualization/api/core" - "github.com/deckhouse/virtualization/api/core/v1alpha2" - - "github.com/deckhouse/module-sdk/pkg" - "github.com/deckhouse/module-sdk/pkg/registry" - - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/utils/ptr" -) - -const ( - moduleStateSecretSnapshot = "module-state-secret" - vmClassSnapshot = "vmclass-generic" - - moduleStateSecretName = "module-state" - genericVMClassName = "generic" - - apiVersion = core.GroupName + "/" + v1alpha2.Version -) - -var _ = registry.RegisterFunc(config, Reconcile) - -var config = &pkg.HookConfig{ - OnBeforeHelm: &pkg.OrderedConfig{Order: 5}, - Kubernetes: []pkg.KubernetesConfig{ - { - Name: moduleStateSecretSnapshot, - APIVersion: "v1", - Kind: "Secret", - JqFilter: `{"metadata": .metadata, "data": .data}`, - NameSelector: &pkg.NameSelector{ - MatchNames: []string{moduleStateSecretName}, - }, - NamespaceSelector: &pkg.NamespaceSelector{ - NameSelector: &pkg.NameSelector{ - MatchNames: []string{settings.ModuleNamespace}, - }, - }, - ExecuteHookOnSynchronization: ptr.To(false), - }, - { - Name: vmClassSnapshot, - APIVersion: apiVersion, - Kind: v1alpha2.VirtualMachineClassKind, - JqFilter: `.metadata.name`, - NameSelector: &pkg.NameSelector{ - MatchNames: []string{genericVMClassName}, - }, - LabelSelector: &metav1.LabelSelector{ - MatchLabels: map[string]string{ - "app": "virtualization-controller", - "module": settings.ModuleName, - }, - }, - ExecuteHookOnSynchronization: ptr.To(false), - }, - }, - - Queue: fmt.Sprintf("modules/%s", settings.ModuleName), -} - -func Reconcile(_ context.Context, input *pkg.HookInput) error { - moduleStateSecrets := input.Snapshots.Get(moduleStateSecretSnapshot) - vmClasses := input.Snapshots.Get(vmClassSnapshot) - - // nothing to do if generic vmclass already exists - if len(vmClasses) > 0 { - return nil - } - - // if module-state secret exists and contains generic-vmclass-was-ever-created=true, nothing to do - if len(moduleStateSecrets) > 0 { - var moduleStateSecret corev1.Secret - if err := moduleStateSecrets[0].UnmarshalTo(&moduleStateSecret); err != nil { - return err - } - - if string(moduleStateSecret.Data["generic-vmclass-was-ever-created"]) == "true" { - return nil - } - } - - input.Logger.Info("Creating generic VirtualMachineClass") - - vmClass := &v1alpha2.VirtualMachineClass{ - TypeMeta: metav1.TypeMeta{ - APIVersion: apiVersion, - Kind: v1alpha2.VirtualMachineClassKind, - }, - ObjectMeta: metav1.ObjectMeta{ - Name: genericVMClassName, - Labels: map[string]string{ - "app": "virtualization-controller", - "module": settings.ModuleName, - }, - }, - Spec: v1alpha2.VirtualMachineClassSpec{ - CPU: v1alpha2.CPU{ - Type: v1alpha2.CPUTypeModel, - Model: "Nehalem", - }, - SizingPolicies: []v1alpha2.SizingPolicy{ - { - Cores: &v1alpha2.SizingPolicyCores{ - Min: 1, - Max: 4, - }, - DedicatedCores: []bool{false}, - CoreFractions: []v1alpha2.CoreFractionValue{5, 10, 20, 50, 100}, - }, - { - Cores: &v1alpha2.SizingPolicyCores{ - Min: 5, - Max: 8, - }, - DedicatedCores: []bool{false}, - CoreFractions: []v1alpha2.CoreFractionValue{20, 50, 100}, - }, - { - Cores: &v1alpha2.SizingPolicyCores{ - Min: 9, - Max: 16, - }, - DedicatedCores: []bool{true, false}, - CoreFractions: []v1alpha2.CoreFractionValue{50, 100}, - }, - { - Cores: &v1alpha2.SizingPolicyCores{ - Min: 17, - Max: 1024, - }, - DedicatedCores: []bool{true, false}, - CoreFractions: []v1alpha2.CoreFractionValue{100}, - }, - }, - }, - } - - input.PatchCollector.Create(vmClass) - input.Logger.Info("VirtualMachineClass generic created") - - return nil -} diff --git a/images/hooks/pkg/hooks/create-generic-vmclass/hook_test.go b/images/hooks/pkg/hooks/create-generic-vmclass/hook_test.go deleted file mode 100644 index 2178ab99f1..0000000000 --- a/images/hooks/pkg/hooks/create-generic-vmclass/hook_test.go +++ /dev/null @@ -1,201 +0,0 @@ -/* -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 create_generic_vmclass - -import ( - "context" - "testing" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - - "github.com/deckhouse/deckhouse/pkg/log" - "github.com/deckhouse/module-sdk/pkg" - "github.com/deckhouse/module-sdk/testing/mock" - "github.com/deckhouse/virtualization/api/core/v1alpha2" - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -func TestCreateGenericVMClass(t *testing.T) { - RegisterFailHandler(Fail) - RunSpecs(t, "Create Generic VMClass Suite") -} - -var _ = Describe("Create Generic VMClass hook", func() { - var ( - snapshots *mock.SnapshotsMock - patchCollector *mock.PatchCollectorMock - ) - - newInput := func() *pkg.HookInput { - return &pkg.HookInput{ - Snapshots: snapshots, - PatchCollector: patchCollector, - Logger: log.NewNop(), - } - } - - BeforeEach(func() { - snapshots = mock.NewSnapshotsMock(GinkgoT()) - patchCollector = mock.NewPatchCollectorMock(GinkgoT()) - }) - - AfterEach(func() { - snapshots = nil - patchCollector = nil - }) - - Context("when module-state secret exists with generic-vmclass-was-ever-created=true", func() { - BeforeEach(func() { - moduleStateSecret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "module-state", - Namespace: "d8-virtualization", - }, - Data: map[string][]byte{ - "generic-vmclass-was-ever-created": []byte("true"), - }, - } - - snapshots.GetMock.When(moduleStateSecretSnapshot).Then([]pkg.Snapshot{ - mock.NewSnapshotMock(GinkgoT()).UnmarshalToMock.Set(func(v any) error { - secret, ok := v.(*corev1.Secret) - Expect(ok).To(BeTrue()) - *secret = *moduleStateSecret - return nil - }), - }) - }) - - It("should not recreate generic vmclass when it doesn't exist but state says it was created (user may have deleted it intentionally)", func() { - snapshots.GetMock.When(vmClassSnapshot).Then([]pkg.Snapshot{}) - - patchCollector.CreateMock.Optional() - - Expect(Reconcile(context.Background(), newInput())).To(Succeed()) - Expect(patchCollector.CreateMock.Calls()).To(HaveLen(0)) - }) - - }) - - Context("when module-state secret doesn't exist", func() { - BeforeEach(func() { - snapshots.GetMock.When(moduleStateSecretSnapshot).Then([]pkg.Snapshot{}) - }) - - It("should create generic vmclass when it doesn't exist", func() { - snapshots.GetMock.When(vmClassSnapshot).Then([]pkg.Snapshot{}) - - patchCollector.CreateMock.Set(func(obj interface{}) { - vmClass, ok := obj.(*v1alpha2.VirtualMachineClass) - Expect(ok).To(BeTrue()) - Expect(vmClass.Name).To(Equal("generic")) - Expect(vmClass.Labels).To(Equal(map[string]string{ - "app": "virtualization-controller", - "module": "virtualization", - })) - }) - - Expect(Reconcile(context.Background(), newInput())).To(Succeed()) - Expect(patchCollector.CreateMock.Calls()).To(HaveLen(1)) - }) - - }) - - Context("when module-state secret exists but doesn't contain generic-vmclass-was-ever-created", func() { - BeforeEach(func() { - moduleStateSecret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "module-state", - Namespace: "d8-virtualization", - }, - Data: map[string][]byte{ - "other-key": []byte("other-value"), - }, - } - - snapshots.GetMock.When(moduleStateSecretSnapshot).Then([]pkg.Snapshot{ - mock.NewSnapshotMock(GinkgoT()).UnmarshalToMock.Set(func(v any) error { - secret, ok := v.(*corev1.Secret) - Expect(ok).To(BeTrue()) - *secret = *moduleStateSecret - return nil - }), - }) - }) - - It("should create generic vmclass when it doesn't exist", func() { - snapshots.GetMock.When(vmClassSnapshot).Then([]pkg.Snapshot{}) - - patchCollector.CreateMock.Set(func(obj interface{}) { - vmClass, ok := obj.(*v1alpha2.VirtualMachineClass) - Expect(ok).To(BeTrue()) - Expect(vmClass.Name).To(Equal("generic")) - Expect(vmClass.Labels).To(Equal(map[string]string{ - "app": "virtualization-controller", - "module": "virtualization", - })) - }) - - Expect(Reconcile(context.Background(), newInput())).To(Succeed()) - Expect(patchCollector.CreateMock.Calls()).To(HaveLen(1)) - }) - - }) - - Context("when module-state secret exists with generic-vmclass-was-ever-created=false", func() { - BeforeEach(func() { - moduleStateSecret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "module-state", - Namespace: "d8-virtualization", - }, - Data: map[string][]byte{ - "generic-vmclass-was-ever-created": []byte("false"), - }, - } - - snapshots.GetMock.When(moduleStateSecretSnapshot).Then([]pkg.Snapshot{ - mock.NewSnapshotMock(GinkgoT()).UnmarshalToMock.Set(func(v any) error { - secret, ok := v.(*corev1.Secret) - Expect(ok).To(BeTrue()) - *secret = *moduleStateSecret - return nil - }), - }) - }) - - It("should create generic vmclass when it doesn't exist", func() { - snapshots.GetMock.When(vmClassSnapshot).Then([]pkg.Snapshot{}) - - patchCollector.CreateMock.Set(func(obj interface{}) { - vmClass, ok := obj.(*v1alpha2.VirtualMachineClass) - Expect(ok).To(BeTrue()) - Expect(vmClass.Name).To(Equal("generic")) - Expect(vmClass.Labels).To(Equal(map[string]string{ - "app": "virtualization-controller", - "module": "virtualization", - })) - }) - - Expect(Reconcile(context.Background(), newInput())).To(Succeed()) - Expect(patchCollector.CreateMock.Calls()).To(HaveLen(1)) - }) - - }) -}) diff --git a/images/hooks/pkg/hooks/drop-helm-labels-from-generic-vmclass/hook.go b/images/hooks/pkg/hooks/drop-helm-labels-from-generic-vmclass/hook.go deleted file mode 100644 index 23185bd5b2..0000000000 --- a/images/hooks/pkg/hooks/drop-helm-labels-from-generic-vmclass/hook.go +++ /dev/null @@ -1,166 +0,0 @@ -/* -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 drop_helm_labels_from_generic_vmclass - -import ( - "context" - "fmt" - "strings" - - "github.com/deckhouse/module-sdk/pkg" - "github.com/deckhouse/module-sdk/pkg/registry" - "github.com/deckhouse/virtualization/api/core/v1alpha2" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/utils/ptr" - - "hooks/pkg/settings" -) - -const ( - vmClassSnapshot = "vmclass-generic" - genericVMClassName = "generic" -) - -const ( - helmManagedByLabel = "app.kubernetes.io/managed-by" - helmHeritageLabel = "heritage" - helmReleaseNameAnno = "meta.helm.sh/release-name" - helmReleaseNamespaceAnno = "meta.helm.sh/release-namespace" -) - -var _ = registry.RegisterFunc(configDropHelmLabels, handlerDropHelmLabels) - -var configDropHelmLabels = &pkg.HookConfig{ - OnAfterHelm: &pkg.OrderedConfig{Order: 20}, - Kubernetes: []pkg.KubernetesConfig{ - { - Name: vmClassSnapshot, - APIVersion: "virtualization.deckhouse.io/v1alpha2", - Kind: v1alpha2.VirtualMachineClassKind, - JqFilter: "{name: .metadata.name, labels: .metadata.labels, annotations: .metadata.annotations}", - NameSelector: &pkg.NameSelector{ - MatchNames: []string{genericVMClassName}, - }, - LabelSelector: &metav1.LabelSelector{ - MatchLabels: map[string]string{ - "app": "virtualization-controller", - "app.kubernetes.io/managed-by": "Helm", - "heritage": "deckhouse", - "module": settings.ModuleName, - }, - }, - ExecuteHookOnEvents: ptr.To(false), - }, - }, - - Queue: fmt.Sprintf("modules/%s", settings.ModuleName), -} - -type VMClassMetadata struct { - Name string `json:"name"` - Labels map[string]string `json:"labels"` - Annotations map[string]string `json:"annotations"` -} - -func handlerDropHelmLabels(_ context.Context, input *pkg.HookInput) error { - snaps := input.Snapshots.Get(vmClassSnapshot) - if len(snaps) == 0 { - return nil - } - - vmClass := &VMClassMetadata{} - err := snaps[0].UnmarshalTo(vmClass) - if err != nil { - input.Logger.Error("failed to unmarshal VMClass", "error", err) - return err - } - - if vmClass.Labels == nil { - return nil - } - - // Check if VMClass has all required labels to be processed - if vmClass.Labels["app"] != "virtualization-controller" || - vmClass.Labels["module"] != settings.ModuleName || - vmClass.Labels[helmManagedByLabel] != "Helm" || - vmClass.Labels[helmHeritageLabel] != "deckhouse" { - input.Logger.Debug("VMClass doesn't match required labels, skipping") - return nil - } - - var patches []map[string]interface{} - hasChanges := false - - // Check and prepare patches for Helm labels - if _, exists := vmClass.Labels[helmManagedByLabel]; exists { - patches = append(patches, map[string]interface{}{ - "op": "remove", - "path": fmt.Sprintf("/metadata/labels/%s", jsonPatchEscape(helmManagedByLabel)), - "value": nil, - }) - hasChanges = true - } - - if _, exists := vmClass.Labels[helmHeritageLabel]; exists { - patches = append(patches, map[string]interface{}{ - "op": "remove", - "path": fmt.Sprintf("/metadata/labels/%s", jsonPatchEscape(helmHeritageLabel)), - "value": nil, - }) - hasChanges = true - } - - // Check and prepare patches for Helm annotations - if vmClass.Annotations != nil { - if releaseName, exists := vmClass.Annotations[helmReleaseNameAnno]; exists && releaseName == settings.ModuleName { - patches = append(patches, map[string]interface{}{ - "op": "remove", - "path": fmt.Sprintf("/metadata/annotations/%s", jsonPatchEscape(helmReleaseNameAnno)), - "value": nil, - }) - hasChanges = true - } - - if releaseNamespace, exists := vmClass.Annotations[helmReleaseNamespaceAnno]; exists && releaseNamespace == settings.ModuleNamespace { - patches = append(patches, map[string]interface{}{ - "op": "remove", - "path": fmt.Sprintf("/metadata/annotations/%s", jsonPatchEscape(helmReleaseNamespaceAnno)), - "value": nil, - }) - hasChanges = true - } - } - - if !hasChanges { - return nil - } - - input.Logger.Info("Removing Helm labels and annotations from generic VMClass") - input.PatchCollector.PatchWithJSON( - patches, - "virtualization.deckhouse.io/v1alpha2", - "VirtualMachineClass", - "", - genericVMClassName, - ) - - return nil -} - -func jsonPatchEscape(s string) string { - return strings.NewReplacer("~", "~0", "/", "~1").Replace(s) -} diff --git a/images/hooks/pkg/hooks/drop-helm-labels-from-generic-vmclass/hook_test.go b/images/hooks/pkg/hooks/drop-helm-labels-from-generic-vmclass/hook_test.go deleted file mode 100644 index 200be33583..0000000000 --- a/images/hooks/pkg/hooks/drop-helm-labels-from-generic-vmclass/hook_test.go +++ /dev/null @@ -1,223 +0,0 @@ -/* -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 drop_helm_labels_from_generic_vmclass - -import ( - "context" - "fmt" - "testing" - - "github.com/deckhouse/deckhouse/pkg/log" - "github.com/deckhouse/module-sdk/pkg" - "github.com/deckhouse/module-sdk/testing/mock" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func TestDropHelmLabelsFromGenericVMClass(t *testing.T) { - RegisterFailHandler(Fail) - RunSpecs(t, "Drop Helm labels from generic VMClass Suite") -} - -var _ = Describe("Drop Helm labels from generic VMClass", func() { - var ( - snapshots *mock.SnapshotsMock - patchCollector *mock.PatchCollectorMock - ) - - newInput := func() *pkg.HookInput { - return &pkg.HookInput{ - Snapshots: snapshots, - PatchCollector: patchCollector, - Logger: log.NewNop(), - } - } - - newSnapshot := func(withManagedBy, withHeritage bool, withAnnotations bool) pkg.Snapshot { - return mock.NewSnapshotMock(GinkgoT()).UnmarshalToMock.Set(func(v any) (err error) { - obj, ok := v.(*VMClassMetadata) - Expect(ok).To(BeTrue()) - obj.Name = genericVMClassName - obj.Labels = make(map[string]string) - - // Required labels for VMClass to be found by the hook - obj.Labels["app"] = "virtualization-controller" - obj.Labels["module"] = "virtualization" - - if withManagedBy { - obj.Labels[helmManagedByLabel] = "Helm" - } - if withHeritage { - obj.Labels[helmHeritageLabel] = "deckhouse" - } - - if withAnnotations { - obj.Annotations = make(map[string]string) - obj.Annotations[helmReleaseNameAnno] = "virtualization" - obj.Annotations[helmReleaseNamespaceAnno] = "d8-virtualization" - } - - return nil - }) - } - - setSnapshots := func(snaps ...pkg.Snapshot) { - snapshots.GetMock.When(vmClassSnapshot).Then(snaps) - } - - BeforeEach(func() { - snapshots = mock.NewSnapshotsMock(GinkgoT()) - patchCollector = mock.NewPatchCollectorMock(GinkgoT()) - }) - - It("Should drop both Helm labels and annotations from generic VMClass with all required labels", func() { - setSnapshots(newSnapshot(true, true, true)) - patchCollector.PatchWithJSONMock.Set(func(patch any, apiVersion, kind, namespace, name string, opts ...pkg.PatchCollectorOption) { - Expect(apiVersion).To(Equal("virtualization.deckhouse.io/v1alpha2")) - Expect(kind).To(Equal("VirtualMachineClass")) - Expect(namespace).To(Equal("")) - Expect(name).To(Equal(genericVMClassName)) - Expect(opts).To(HaveLen(0)) - - jsonPatch, ok := patch.([]map[string]interface{}) - Expect(ok).To(BeTrue()) - Expect(jsonPatch).To(HaveLen(4)) - - // Check first patch (managed-by label) - Expect(jsonPatch[0]["op"]).To(Equal("remove")) - Expect(jsonPatch[0]["path"]).To(Equal(fmt.Sprintf("/metadata/labels/%s", jsonPatchEscape(helmManagedByLabel)))) - Expect(jsonPatch[0]["value"]).To(BeNil()) - - // Check second patch (heritage label) - Expect(jsonPatch[1]["op"]).To(Equal("remove")) - Expect(jsonPatch[1]["path"]).To(Equal(fmt.Sprintf("/metadata/labels/%s", jsonPatchEscape(helmHeritageLabel)))) - Expect(jsonPatch[1]["value"]).To(BeNil()) - - // Check third patch (release-name annotation) - Expect(jsonPatch[2]["op"]).To(Equal("remove")) - Expect(jsonPatch[2]["path"]).To(Equal(fmt.Sprintf("/metadata/annotations/%s", jsonPatchEscape(helmReleaseNameAnno)))) - Expect(jsonPatch[2]["value"]).To(BeNil()) - - // Check fourth patch (release-namespace annotation) - Expect(jsonPatch[3]["op"]).To(Equal("remove")) - Expect(jsonPatch[3]["path"]).To(Equal(fmt.Sprintf("/metadata/annotations/%s", jsonPatchEscape(helmReleaseNamespaceAnno)))) - Expect(jsonPatch[3]["value"]).To(BeNil()) - }) - - Expect(handlerDropHelmLabels(context.Background(), newInput())).To(Succeed()) - }) - - It("Should do nothing when VMClass doesn't have all required labels", func() { - // Create a snapshot with VMClass that has only some required labels - partialLabelSnapshot := mock.NewSnapshotMock(GinkgoT()).UnmarshalToMock.Set(func(v any) (err error) { - obj, ok := v.(*VMClassMetadata) - Expect(ok).To(BeTrue()) - obj.Name = genericVMClassName - obj.Labels = make(map[string]string) - - // Only some required labels - VMClass won't be processed - obj.Labels["app"] = "virtualization-controller" - obj.Labels["module"] = "virtualization" - // Missing helmManagedByLabel and helmHeritageLabel - - return nil - }) - - setSnapshots(partialLabelSnapshot) - Expect(handlerDropHelmLabels(context.Background(), newInput())).To(Succeed()) - }) - - It("Should drop only labels when annotations are missing", func() { - setSnapshots(newSnapshot(true, true, false)) - patchCollector.PatchWithJSONMock.Set(func(patch any, apiVersion, kind, namespace, name string, opts ...pkg.PatchCollectorOption) { - jsonPatch, ok := patch.([]map[string]interface{}) - Expect(ok).To(BeTrue()) - Expect(jsonPatch).To(HaveLen(2)) - - // Check first patch (managed-by label) - Expect(jsonPatch[0]["op"]).To(Equal("remove")) - Expect(jsonPatch[0]["path"]).To(Equal(fmt.Sprintf("/metadata/labels/%s", jsonPatchEscape(helmManagedByLabel)))) - - // Check second patch (heritage label) - Expect(jsonPatch[1]["op"]).To(Equal("remove")) - Expect(jsonPatch[1]["path"]).To(Equal(fmt.Sprintf("/metadata/labels/%s", jsonPatchEscape(helmHeritageLabel)))) - }) - - Expect(handlerDropHelmLabels(context.Background(), newInput())).To(Succeed()) - }) - - It("Should not drop annotations with wrong values", func() { - wrongAnnotationSnapshot := mock.NewSnapshotMock(GinkgoT()).UnmarshalToMock.Set(func(v any) (err error) { - obj, ok := v.(*VMClassMetadata) - Expect(ok).To(BeTrue()) - obj.Name = genericVMClassName - obj.Labels = make(map[string]string) - - // Required labels for VMClass to be found by the hook - obj.Labels["app"] = "virtualization-controller" - obj.Labels["module"] = "virtualization" - obj.Labels[helmManagedByLabel] = "Helm" - obj.Labels[helmHeritageLabel] = "deckhouse" - - // Annotations with wrong values - should not be removed - obj.Annotations = make(map[string]string) - obj.Annotations[helmReleaseNameAnno] = "wrong-module-name" - obj.Annotations[helmReleaseNamespaceAnno] = "wrong-namespace" - - return nil - }) - - setSnapshots(wrongAnnotationSnapshot) - patchCollector.PatchWithJSONMock.Set(func(patch any, apiVersion, kind, namespace, name string, opts ...pkg.PatchCollectorOption) { - jsonPatch, ok := patch.([]map[string]interface{}) - Expect(ok).To(BeTrue()) - Expect(jsonPatch).To(HaveLen(2)) - - // Only labels should be removed, not annotations with wrong values - Expect(jsonPatch[0]["path"]).To(ContainSubstring("/metadata/labels/")) - Expect(jsonPatch[1]["path"]).To(ContainSubstring("/metadata/labels/")) - }) - - Expect(handlerDropHelmLabels(context.Background(), newInput())).To(Succeed()) - }) - - It("Should do nothing when VMClass not found", func() { - setSnapshots() - Expect(handlerDropHelmLabels(context.Background(), newInput())).To(Succeed()) - }) - - It("Should do nothing when VMClass exists but doesn't match label selector", func() { - // Create a snapshot with VMClass that has wrong labels - wrongLabelSnapshot := mock.NewSnapshotMock(GinkgoT()).UnmarshalToMock.Set(func(v any) (err error) { - obj, ok := v.(*VMClassMetadata) - Expect(ok).To(BeTrue()) - obj.Name = genericVMClassName - obj.Labels = make(map[string]string) - - // Wrong labels - VMClass won't be found by the hook - obj.Labels["app"] = "wrong-app" - obj.Labels["module"] = "wrong-module" - obj.Labels[helmManagedByLabel] = "Helm" - obj.Labels[helmHeritageLabel] = "deckhouse" - - return nil - }) - - setSnapshots(wrongLabelSnapshot) - Expect(handlerDropHelmLabels(context.Background(), newInput())).To(Succeed()) - }) -}) diff --git a/images/hooks/pkg/hooks/install-vmclass-generic/hook.go b/images/hooks/pkg/hooks/install-vmclass-generic/hook.go new file mode 100644 index 0000000000..0991817f3e --- /dev/null +++ b/images/hooks/pkg/hooks/install-vmclass-generic/hook.go @@ -0,0 +1,326 @@ +/* +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 install_vmclass_generic + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "hooks/pkg/settings" + + "github.com/deckhouse/virtualization/api/core/v1alpha2" + + "github.com/deckhouse/module-sdk/pkg" + "github.com/deckhouse/module-sdk/pkg/registry" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" +) + +const ( + moduleStateSecretSnapshot = "module-state-snapshot" + moduleStateSecretName = "module-state" + + vmClassGenericSnapshot = "vmclass-generic-snapshot" + vmClassGenericName = "generic" + + vmClassInstallationStateSecretKey = "vmClassGenericInstallation" + vmClassInstallationStateValuesPath = "virtualization.internal.moduleState." + vmClassInstallationStateSecretKey +) + +var _ = registry.RegisterFunc(config, Reconcile) + +// This hook runs before applying templates (OnBeforeHelm) to drop helm labels +// and make vmclass unmanageable. +var config = &pkg.HookConfig{ + OnBeforeHelm: &pkg.OrderedConfig{Order: 5}, + Kubernetes: []pkg.KubernetesConfig{ + { + Name: moduleStateSecretSnapshot, + APIVersion: "v1", + Kind: "Secret", + JqFilter: `{data}`, + NameSelector: &pkg.NameSelector{ + MatchNames: []string{moduleStateSecretName}, + }, + NamespaceSelector: &pkg.NamespaceSelector{ + NameSelector: &pkg.NameSelector{ + MatchNames: []string{settings.ModuleNamespace}, + }, + }, + ExecuteHookOnSynchronization: ptr.To(false), + ExecuteHookOnEvents: ptr.To(false), + }, + { + Name: vmClassGenericSnapshot, + Kind: v1alpha2.VirtualMachineClassKind, + JqFilter: `{apiVersion, kind, "metadata": ( .metadata | {name, labels, annotations, creationTimestamp} ) }`, + NameSelector: &pkg.NameSelector{ + MatchNames: []string{vmClassGenericName}, + }, + ExecuteHookOnSynchronization: ptr.To(false), + ExecuteHookOnEvents: ptr.To(false), + }, + }, + + Queue: fmt.Sprintf("modules/%s", settings.ModuleName), +} + +// Reconcile manages the state of vmclass/generic resource: +// +// - Install a new one if there is no state in the Secret indicating that the vmclass was installed earlier. +// - Removes helm related annotations and labels from existing vmclass/generic (one time operation). +// - No actions performed if user deletes or replaces vmclass/generic. +func Reconcile(_ context.Context, input *pkg.HookInput) error { + moduleState, err := parseVMClassInstallationStateFromSnapshot(input) + if err != nil { + return err + } + + // If there is a state for vmclass/generic in the Secret, no changes to vmclass is required. + // Presence of the vmclass is not important, user may delete it and it's ok. + // The important part is to copy state from the Secret into values + // to ensure correct manifest for the Secret template (there may be no state in values, e.g. after deckhouse restart). + if moduleState != nil { + input.Values.Set(vmClassInstallationStateValuesPath, vmClassInstallationState{InstalledAt: moduleState.InstalledAt}) + return nil + } + + // Corner case: the secret is gone, but the state is present in values. + // Just return without changes to vmclass/generic, so helm will re-create + // the Secret with the module state. + stateInValues := input.Values.Get(vmClassInstallationStateValuesPath) + if stateInValues.Exists() { + return nil + } + + vmClassGeneric, err := parseVMClassGenericFromSnapshot(input) + if err != nil { + return err + } + + // No state in secret, no state in values, no vmclass/generic. + // Create vmclass/generic and set state in values, as it should be initial module installation. + if vmClassGeneric == nil { + input.Logger.Info("Install VirtualMachineClass/generic") + vmClass := vmClassGenericManifest() + input.PatchCollector.Create(vmClass) + } + // No state in secret, no state in values, but vmclass/generic is present. + // Cleanup metadata if vmclass was created by earlier versions of the module. + if isManagedByModule(vmClassGeneric) { + addPatchesToCleanupMetadata(input, vmClassGeneric) + } + + // Set state in values to prevent any further updates to vmclass/generic. + input.Values.Set(vmClassInstallationStateValuesPath, vmClassInstallationState{InstalledAt: time.Now()}) + return nil +} + +type vmClassInstallationState struct { + InstalledAt time.Time `json:"installedAt"` +} + +// parseVMClassInstallationStateFromSnapshot unmarshal vmClassInstallationState from jqFilter result. +func parseVMClassInstallationStateFromSnapshot(input *pkg.HookInput) (*vmClassInstallationState, error) { + snap := input.Snapshots.Get(moduleStateSecretSnapshot) + if len(snap) < 1 { + return nil, nil + } + + var ms corev1.Secret + err := snap[0].UnmarshalTo(&ms) + if err != nil { + return nil, err + } + + stateRaw := ms.Data[vmClassInstallationStateSecretKey] + if len(stateRaw) == 0 { + return nil, nil + } + + var s vmClassInstallationState + err = json.Unmarshal(stateRaw, &s) + if err != nil { + return nil, fmt.Errorf("restore vmclass generic state from secret: %w", err) + } + + return &s, nil +} + +// parseVMClassGenericFromSnapshot unmarshal ModuleConfig from jqFilter result. +func parseVMClassGenericFromSnapshot(input *pkg.HookInput) (*v1alpha2.VirtualMachineClass, error) { + snap := input.Snapshots.Get(vmClassGenericSnapshot) + if len(snap) < 1 { + return nil, nil + } + + var vmclass v1alpha2.VirtualMachineClass + err := snap[0].UnmarshalTo(&vmclass) + if err != nil { + return nil, err + } + return &vmclass, nil +} + +// vmClassGenericManifest returns a manifest for 'generic' vmclass +// that should work for VM on every Node in cluster. +func vmClassGenericManifest() *v1alpha2.VirtualMachineClass { + return &v1alpha2.VirtualMachineClass{ + TypeMeta: metav1.TypeMeta{ + APIVersion: v1alpha2.SchemeGroupVersion.String(), + Kind: v1alpha2.VirtualMachineClassKind, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: vmClassGenericName, + Labels: map[string]string{ + "app": "virtualization-controller", + "module": settings.ModuleName, + }, + }, + Spec: v1alpha2.VirtualMachineClassSpec{ + CPU: v1alpha2.CPU{ + Type: v1alpha2.CPUTypeModel, + Model: "Nehalem", + }, + SizingPolicies: []v1alpha2.SizingPolicy{ + { + Cores: &v1alpha2.SizingPolicyCores{ + Min: 1, + Max: 4, + }, + DedicatedCores: []bool{false}, + CoreFractions: []v1alpha2.CoreFractionValue{5, 10, 20, 50, 100}, + }, + { + Cores: &v1alpha2.SizingPolicyCores{ + Min: 5, + Max: 8, + }, + DedicatedCores: []bool{false}, + CoreFractions: []v1alpha2.CoreFractionValue{20, 50, 100}, + }, + { + Cores: &v1alpha2.SizingPolicyCores{ + Min: 9, + Max: 16, + }, + DedicatedCores: []bool{true, false}, + CoreFractions: []v1alpha2.CoreFractionValue{50, 100}, + }, + { + Cores: &v1alpha2.SizingPolicyCores{ + Min: 17, + Max: 1024, + }, + DedicatedCores: []bool{true, false}, + CoreFractions: []v1alpha2.CoreFractionValue{100}, + }, + }, + }, + } +} + +// isManagedByModule checks if vmclass has all labels that module set when installing vmclass. +func isManagedByModule(vmClass *v1alpha2.VirtualMachineClass) bool { + if vmClass == nil { + return false + } + + expectLabels := vmClassGenericManifest().Labels + + for label, expectValue := range expectLabels { + actualValue, exists := vmClass.Labels[label] + if !exists || actualValue != expectValue { + return false + } + } + return true +} + +const ( + heritageLabel = "heritage" + helmManagedByLabel = "app.kubernetes.io/managed-by" + helmReleaseNameAnno = "meta.helm.sh/release-name" + helmReleaseNamespaceAnno = "meta.helm.sh/release-namespace" + helmKeepResourceAnno = "helm.sh/resource-policy" +) + +// addPatchesToCleanupMetadata fills patch collector with patches if vmclass metadata +// should be cleaned. +func addPatchesToCleanupMetadata(input *pkg.HookInput, vmClass *v1alpha2.VirtualMachineClass) { + var patches []map[string]interface{} + + labelNames := []string{ + heritageLabel, + helmManagedByLabel, + } + for _, labelName := range labelNames { + if _, exists := vmClass.Labels[labelName]; exists { + patches = append(patches, map[string]interface{}{ + "op": "remove", + "path": fmt.Sprintf("/metadata/labels/%s", jsonPatchEscape(labelName)), + "value": nil, + }) + } + } + + // Ensure "keep resource" annotation on vmclass/generic, so Helm will keep it + // in the cluster even that we've deleted its manifest from templates. + if _, exists := vmClass.Annotations[helmKeepResourceAnno]; !exists { + patches = append(patches, map[string]interface{}{ + "op": "add", + "path": fmt.Sprintf("/metadata/annotations/%s", jsonPatchEscape(helmKeepResourceAnno)), + "value": nil, + }) + } + + annoNames := []string{ + helmReleaseNameAnno, + helmReleaseNamespaceAnno, + } + for _, annoName := range annoNames { + if _, exists := vmClass.Annotations[annoName]; exists { + patches = append(patches, map[string]interface{}{ + "op": "remove", + "path": fmt.Sprintf("/metadata/annotations/%s", jsonPatchEscape(annoName)), + "value": nil, + }) + } + } + + if len(patches) == 0 { + return + } + + input.Logger.Info("Patch VirtualMachineClass/generic: remove Helm labels and annotations") + input.PatchCollector.PatchWithJSON( + patches, + vmClass.APIVersion, + vmClass.Kind, + vmClass.Namespace, + vmClass.Name, + ) +} + +func jsonPatchEscape(s string) string { + return strings.NewReplacer("~", "~0", "/", "~1").Replace(s) +} diff --git a/images/hooks/pkg/hooks/install-vmclass-generic/hook_test.go b/images/hooks/pkg/hooks/install-vmclass-generic/hook_test.go new file mode 100644 index 0000000000..ad702941b2 --- /dev/null +++ b/images/hooks/pkg/hooks/install-vmclass-generic/hook_test.go @@ -0,0 +1,382 @@ +/* +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 install_vmclass_generic + +import ( + "context" + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/tidwall/gjson" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/deckhouse/deckhouse/pkg/log" + "github.com/deckhouse/module-sdk/pkg" + "github.com/deckhouse/module-sdk/testing/mock" + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +func Test_InstallVMClassGeneric(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Create Generic VMClass Suite") +} + +var _ = Describe("Install VMClass Generic hook", func() { + var ( + snapshots *mock.SnapshotsMock + values *mock.OutputPatchableValuesCollectorMock + patchCollector *mock.PatchCollectorMock + ) + + newInput := func() *pkg.HookInput { + return &pkg.HookInput{ + Snapshots: snapshots, + Values: values, + PatchCollector: patchCollector, + Logger: log.NewNop(), + } + } + + prepareStateValuesEmpty := func() { + values.GetMock.When(vmClassInstallationStateValuesPath).Then(gjson.Result{Type: gjson.Null}) + } + + prepareStateValuesInstalled := func() { + values.GetMock.When(vmClassInstallationStateValuesPath).Then(gjson.Result{ + Type: gjson.String, + Str: `{"installedAt":"2020-01-01T00:00:00Z"}`, + }) + } + + prepareModuleStateSnapshotEmpty := func() { + snapshots.GetMock.When(moduleStateSecretSnapshot).Then([]pkg.Snapshot{}) + } + + prepareModuleStateSnapshotValid := func() { + moduleStateSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "module-state", + Namespace: "d8-virtualization", + }, + Data: map[string][]byte{ + vmClassInstallationStateSecretKey: []byte(`{"installedAt":"2020-01-01T00:00:00Z"}`), + }, + } + + snapshots.GetMock.When(moduleStateSecretSnapshot).Then([]pkg.Snapshot{ + mock.NewSnapshotMock(GinkgoT()).UnmarshalToMock.Set(func(v any) error { + secret, ok := v.(*corev1.Secret) + Expect(ok).To(BeTrue()) + *secret = *moduleStateSecret + return nil + }), + }) + } + + prepareModuleStateSnapshotNoVMClassState := func() { + moduleStateSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "module-state", + Namespace: "d8-virtualization", + }, + Data: map[string][]byte{ + "other-key": []byte(`"other-value"`), + }, + } + + snapshots.GetMock.When(moduleStateSecretSnapshot).Then([]pkg.Snapshot{ + mock.NewSnapshotMock(GinkgoT()).UnmarshalToMock.Set(func(v any) error { + secret, ok := v.(*corev1.Secret) + Expect(ok).To(BeTrue()) + *secret = *moduleStateSecret + return nil + }), + }) + } + + prepareVMClassSnapshotEmpty := func() { + snapshots.GetMock.When(vmClassGenericSnapshot).Then([]pkg.Snapshot{}) + } + + prepareVMClassSnapshotGeneric := func() { + vmClass := vmClassGenericManifest().DeepCopy() + vmClass.Annotations = map[string]string{ + helmKeepResourceAnno: "keep", + } + snapshots.GetMock.When(vmClassGenericSnapshot).Then([]pkg.Snapshot{ + mock.NewSnapshotMock(GinkgoT()).UnmarshalToMock.Set(func(v any) error { + vmClassInSnapshot, ok := v.(*v1alpha2.VirtualMachineClass) + Expect(ok).To(BeTrue()) + *vmClassInSnapshot = *vmClass + return nil + }), + }) + } + + prepareVMClassSnapshotGenericWithoutKeepResource := func() { + vmClass := vmClassGenericManifest().DeepCopy() + snapshots.GetMock.When(vmClassGenericSnapshot).Then([]pkg.Snapshot{ + mock.NewSnapshotMock(GinkgoT()).UnmarshalToMock.Set(func(v any) error { + vmClassInSnapshot, ok := v.(*v1alpha2.VirtualMachineClass) + Expect(ok).To(BeTrue()) + *vmClassInSnapshot = *vmClass + return nil + }), + }) + } + + prepareVMClassSnapshotCustom := func() { + vmClass := vmClassGenericManifest().DeepCopy() + vmClass.Labels = map[string]string{ + "created-by": "user", + } + vmClass.Annotations = nil + snapshots.GetMock.When(vmClassGenericSnapshot).Then([]pkg.Snapshot{ + mock.NewSnapshotMock(GinkgoT()).UnmarshalToMock.Set(func(v any) error { + vmClassInSnapshot, ok := v.(*v1alpha2.VirtualMachineClass) + Expect(ok).To(BeTrue()) + *vmClassInSnapshot = *vmClass + return nil + }), + }) + } + + prepareVMClassSnapshotGenericHelmManaged := func() { + vmClass := vmClassGenericManifest().DeepCopy() + // Keep app, heritage, and module labels. + vmClass.Labels[helmManagedByLabel] = "Helm" + vmClass.Annotations = map[string]string{ + helmReleaseNameAnno: "somename", + helmReleaseNamespaceAnno: "some ns", + } + snapshots.GetMock.When(vmClassGenericSnapshot).Then([]pkg.Snapshot{ + mock.NewSnapshotMock(GinkgoT()).UnmarshalToMock.Set(func(v any) error { + vmClassInSnapshot, ok := v.(*v1alpha2.VirtualMachineClass) + Expect(ok).To(BeTrue()) + *vmClassInSnapshot = *vmClass + return nil + }), + }) + } + + prepareVMClassSnapshotGenericCustomHelmManaged := func() { + vmClass := vmClassGenericManifest().DeepCopy() + vmClass.Labels = map[string]string{ + "created-by": "user", + helmManagedByLabel: "Helm", + } + vmClass.Annotations = map[string]string{ + helmReleaseNameAnno: "somename", + helmReleaseNamespaceAnno: "some ns", + } + snapshots.GetMock.When(vmClassGenericSnapshot).Then([]pkg.Snapshot{ + mock.NewSnapshotMock(GinkgoT()).UnmarshalToMock.Set(func(v any) error { + vmClassInSnapshot, ok := v.(*v1alpha2.VirtualMachineClass) + Expect(ok).To(BeTrue()) + *vmClassInSnapshot = *vmClass + return nil + }), + }) + } + + expectVMClassGeneric := func(obj interface{}) { + GinkgoHelper() + vmClass, ok := obj.(*v1alpha2.VirtualMachineClass) + Expect(ok).To(BeTrue()) + Expect(vmClass.Name).To(Equal("generic")) + Expect(vmClass.Labels).To(Equal(map[string]string{ + "app": "virtualization-controller", + "module": "virtualization", + })) + } + + BeforeEach(func() { + snapshots = mock.NewSnapshotsMock(GinkgoT()) + values = mock.NewPatchableValuesCollectorMock(GinkgoT()) + patchCollector = mock.NewPatchCollectorMock(GinkgoT()) + }) + + AfterEach(func() { + snapshots = nil + values = nil + patchCollector = nil + }) + + When("module-state secret has the vmclass state", func() { + It("should set values and not recreate or patch vmclass/generic", func() { + prepareModuleStateSnapshotValid() + + patchCollector.CreateMock.Optional() + patchCollector.PatchWithJSONMock.Optional() + values.SetMock.Return() + + Expect(Reconcile(context.Background(), newInput())).To(Succeed()) + Expect(patchCollector.CreateMock.Calls()).To(HaveLen(0)) + Expect(patchCollector.PatchWithJSONMock.Calls()).To(HaveLen(0)) + Expect(values.SetMock.Calls()).To(HaveLen(1), "should set values from the Secret") + }) + }) + + When("no module-state secret and no vmclass", func() { + BeforeEach(func() { + prepareModuleStateSnapshotEmpty() + }) + + When("no state in values and no vmclass", func() { + It("should create vmclass/generic and set values", func() { + prepareVMClassSnapshotEmpty() + prepareStateValuesEmpty() + + values.SetMock.Return() + patchCollector.CreateMock.Set(expectVMClassGeneric) + + Expect(Reconcile(context.Background(), newInput())).To(Succeed()) + Expect(patchCollector.CreateMock.Calls()).To(HaveLen(1), "should call Create once") + Expect(values.SetMock.Calls()).To(HaveLen(1), "should call values.Set once") + }) + }) + When("state is present in values", func() { + It("should not create vmclass/generic ans set values", func() { + prepareStateValuesInstalled() + + values.SetMock.Optional() + patchCollector.CreateMock.Optional() + + Expect(Reconcile(context.Background(), newInput())).To(Succeed()) + Expect(patchCollector.CreateMock.Calls()).To(HaveLen(0)) + Expect(values.SetMock.Calls()).To(HaveLen(0)) + }) + }) + }) + + When("module-state secret is present without vmclass state", func() { + BeforeEach(func() { + prepareModuleStateSnapshotNoVMClassState() + }) + + When("state is in values", func() { + It("should not change vmclass/generic", func() { + prepareStateValuesInstalled() + + values.SetMock.Optional() + patchCollector.CreateMock.Optional() + patchCollector.PatchWithJSONMock.Optional() + + Expect(Reconcile(context.Background(), newInput())).To(Succeed()) + Expect(patchCollector.CreateMock.Calls()).To(HaveLen(0)) + Expect(patchCollector.PatchWithJSONMock.Calls()).To(HaveLen(0)) + Expect(values.SetMock.Calls()).To(HaveLen(0)) + }) + }) + + When("no state in values", func() { + BeforeEach(func() { + prepareStateValuesEmpty() + }) + + When("no vmclass/generic", func() { + It("should create vmclass/generic and set values", func() { + prepareVMClassSnapshotEmpty() + + values.SetMock.Return() + patchCollector.CreateMock.Set(expectVMClassGeneric) + + Expect(Reconcile(context.Background(), newInput())).To(Succeed()) + Expect(patchCollector.CreateMock.Calls()).To(HaveLen(1)) + Expect(values.SetMock.Calls()).To(HaveLen(1)) + }) + }) + + When("vmclass/generic is present", func() { + It("should not change vmclass/generic and set values", func() { + prepareVMClassSnapshotGeneric() + + values.SetMock.Return() + patchCollector.CreateMock.Optional() + patchCollector.PatchWithJSONMock.Optional() + + Expect(Reconcile(context.Background(), newInput())).To(Succeed()) + Expect(patchCollector.CreateMock.Calls()).To(HaveLen(0)) + Expect(patchCollector.PatchWithJSONMock.Calls()).To(HaveLen(0)) + Expect(values.SetMock.Calls()).To(HaveLen(1)) + }) + }) + + When("vmclass/generic without keep-resource annotation is present", func() { + It("should not change vmclass/generic and set values", func() { + prepareVMClassSnapshotGenericWithoutKeepResource() + + values.SetMock.Return() + patchCollector.CreateMock.Optional() + patchCollector.PatchWithJSONMock.Return() + + Expect(Reconcile(context.Background(), newInput())).To(Succeed()) + Expect(patchCollector.CreateMock.Calls()).To(HaveLen(0)) + Expect(patchCollector.PatchWithJSONMock.Calls()).To(HaveLen(1)) + Expect(values.SetMock.Calls()).To(HaveLen(1)) + }) + }) + + When("vmclass/generic has helm label", func() { + It("should set values and remove helm labels", func() { + prepareVMClassSnapshotGenericHelmManaged() + + patchCollector.CreateMock.Optional() + patchCollector.PatchWithJSONMock.Return() + values.SetMock.Return() + + Expect(Reconcile(context.Background(), newInput())).To(Succeed()) + Expect(patchCollector.CreateMock.Calls()).To(HaveLen(0)) + Expect(patchCollector.PatchWithJSONMock.Calls()).To(HaveLen(1)) + Expect(values.SetMock.Calls()).To(HaveLen(1), "should set values from the Secret") + }) + }) + + When("custom vmclass/generic is present", func() { + It("should set values and not patch vmclass/generic", func() { + prepareVMClassSnapshotCustom() + + patchCollector.CreateMock.Optional() + patchCollector.PatchWithJSONMock.Optional() + values.SetMock.Return() + + Expect(Reconcile(context.Background(), newInput())).To(Succeed()) + Expect(patchCollector.CreateMock.Calls()).To(HaveLen(0)) + Expect(patchCollector.PatchWithJSONMock.Calls()).To(HaveLen(0)) + Expect(values.SetMock.Calls()).To(HaveLen(1), "should set values from the Secret") + }) + }) + + When("custom vmclass/generic has helm label", func() { + It("should set values and not remove helm values", func() { + prepareVMClassSnapshotGenericCustomHelmManaged() + + patchCollector.CreateMock.Optional() + patchCollector.PatchWithJSONMock.Optional() + values.SetMock.Return() + + Expect(Reconcile(context.Background(), newInput())).To(Succeed()) + Expect(patchCollector.CreateMock.Calls()).To(HaveLen(0)) + Expect(patchCollector.PatchWithJSONMock.Calls()).To(HaveLen(0)) + Expect(values.SetMock.Calls()).To(HaveLen(1), "should set values from the Secret") + }) + }) + }) + }) +}) diff --git a/images/hooks/pkg/hooks/update-module-state/hook.go b/images/hooks/pkg/hooks/update-module-state/hook.go deleted file mode 100644 index 2de434d2db..0000000000 --- a/images/hooks/pkg/hooks/update-module-state/hook.go +++ /dev/null @@ -1,156 +0,0 @@ -/* -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 update_module_state - -import ( - "context" - "encoding/base64" - "fmt" - - "hooks/pkg/settings" - - "github.com/deckhouse/virtualization/api/core" - "github.com/deckhouse/virtualization/api/core/v1alpha2" - - "github.com/deckhouse/module-sdk/pkg" - "github.com/deckhouse/module-sdk/pkg/registry" - - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/utils/ptr" -) - -const ( - updateModuleStateHookName = "Update module-state secret" - vmClassSnapshot = "vmclass-generic" - moduleStateSecretSnapshot = "module-state-secret" - - genericVMClassName = "generic" - moduleStateSecretName = "module-state" - - apiVersion = core.GroupName + "/" + v1alpha2.Version - - // State fields configuration - genericVMClassStateKey = "generic-vmclass-was-ever-created" -) - -var _ = registry.RegisterFunc(config, Reconcile) - -var config = &pkg.HookConfig{ - OnBeforeHelm: &pkg.OrderedConfig{Order: 15}, - Kubernetes: []pkg.KubernetesConfig{ - { - Name: vmClassSnapshot, - APIVersion: apiVersion, - Kind: v1alpha2.VirtualMachineClassKind, - JqFilter: `.metadata.name`, - NameSelector: &pkg.NameSelector{ - MatchNames: []string{genericVMClassName}, - }, - LabelSelector: &metav1.LabelSelector{ - MatchLabels: map[string]string{ - "app": "virtualization-controller", - "module": settings.ModuleName, - }, - }, - ExecuteHookOnSynchronization: ptr.To(false), - }, - { - Name: moduleStateSecretSnapshot, - APIVersion: "v1", - Kind: "Secret", - JqFilter: `{"metadata": .metadata, "data": .data}`, - NameSelector: &pkg.NameSelector{ - MatchNames: []string{moduleStateSecretName}, - }, - NamespaceSelector: &pkg.NamespaceSelector{ - NameSelector: &pkg.NameSelector{ - MatchNames: []string{settings.ModuleNamespace}, - }, - }, - ExecuteHookOnSynchronization: ptr.To(false), - }, - }, - - Queue: fmt.Sprintf("modules/%s", settings.ModuleName), -} - -type ModuleState struct { - GenericVMClassCreated bool -} - -func (ms ModuleState) ToSecretData() map[string][]byte { - value := fmt.Sprintf("%t", ms.GenericVMClassCreated) - return map[string][]byte{ - genericVMClassStateKey: []byte(value), - } -} - -func (ms ModuleState) ToPatchData() map[string]interface{} { - value := fmt.Sprintf("%t", ms.GenericVMClassCreated) - return map[string]interface{}{ - "data": map[string]string{ - genericVMClassStateKey: base64.StdEncoding.EncodeToString([]byte(value)), - }, - } -} - -func Reconcile(_ context.Context, input *pkg.HookInput) error { - vmClasses := input.Snapshots.Get(vmClassSnapshot) - moduleStateSecrets := input.Snapshots.Get(moduleStateSecretSnapshot) - - vmClassExists := len(vmClasses) > 0 - - // Load existing state - currentState := ModuleState{GenericVMClassCreated: false} - if len(moduleStateSecrets) > 0 { - var moduleStateSecret corev1.Secret - if err := moduleStateSecrets[0].UnmarshalTo(&moduleStateSecret); err == nil { - if string(moduleStateSecret.Data[genericVMClassStateKey]) == "true" { - currentState.GenericVMClassCreated = true - } - } - } - - // Update state: generic-vmclass-was-ever-created can only transition from false to true - newState := ModuleState{ - GenericVMClassCreated: currentState.GenericVMClassCreated || vmClassExists, - } - - // Always ensure secret exists with current state - if len(moduleStateSecrets) > 0 { - input.PatchCollector.PatchWithMerge(newState.ToPatchData(), "v1", "Secret", settings.ModuleNamespace, moduleStateSecretName) - } else { - secret := &corev1.Secret{ - TypeMeta: metav1.TypeMeta{ - APIVersion: "v1", - Kind: "Secret", - }, - ObjectMeta: metav1.ObjectMeta{ - Name: moduleStateSecretName, - Namespace: settings.ModuleNamespace, - Labels: map[string]string{ - "module": settings.ModuleName, - }, - }, - Data: newState.ToSecretData(), - Type: "Opaque", - } - input.PatchCollector.Create(secret) - } - - return nil -} diff --git a/images/hooks/pkg/hooks/update-module-state/hook_test.go b/images/hooks/pkg/hooks/update-module-state/hook_test.go deleted file mode 100644 index 23518b192c..0000000000 --- a/images/hooks/pkg/hooks/update-module-state/hook_test.go +++ /dev/null @@ -1,322 +0,0 @@ -/* -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 update_module_state - -import ( - "context" - "encoding/base64" - "testing" - - "hooks/pkg/settings" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - - "github.com/deckhouse/deckhouse/pkg/log" - "github.com/deckhouse/module-sdk/pkg" - "github.com/deckhouse/module-sdk/testing/mock" - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -func TestUpdateModuleState(t *testing.T) { - RegisterFailHandler(Fail) - RunSpecs(t, "Update Module State Suite") -} - -var _ = Describe("Update Module State hook", func() { - var ( - snapshots *mock.SnapshotsMock - patchCollector *mock.PatchCollectorMock - ) - - newInput := func() *pkg.HookInput { - return &pkg.HookInput{ - Snapshots: snapshots, - PatchCollector: patchCollector, - Logger: log.NewNop(), - } - } - - BeforeEach(func() { - snapshots = mock.NewSnapshotsMock(GinkgoT()) - patchCollector = mock.NewPatchCollectorMock(GinkgoT()) - }) - - AfterEach(func() { - snapshots = nil - patchCollector = nil - }) - - Context("when generic vmclass exists", func() { - BeforeEach(func() { - snapshots.GetMock.When(vmClassSnapshot).Then([]pkg.Snapshot{ - mock.NewSnapshotMock(GinkgoT()), - }) - }) - - It("should create module-state secret when it doesn't exist", func() { - snapshots.GetMock.When(moduleStateSecretSnapshot).Then([]pkg.Snapshot{}) - - patchCollector.CreateMock.Set(func(obj interface{}) { - secret, ok := obj.(*corev1.Secret) - Expect(ok).To(BeTrue()) - Expect(secret.Name).To(Equal("module-state")) - Expect(secret.Namespace).To(Equal(settings.ModuleNamespace)) - Expect(secret.Data).To(HaveKey("generic-vmclass-was-ever-created")) - - Expect(string(secret.Data["generic-vmclass-was-ever-created"])).To(Equal("true")) - }) - - patchCollector.PatchWithMergeMock.Optional() - - Expect(Reconcile(context.Background(), newInput())).To(Succeed()) - Expect(patchCollector.CreateMock.Calls()).To(HaveLen(1)) - Expect(patchCollector.PatchWithMergeMock.Calls()).To(HaveLen(0)) - }) - - It("should update module-state secret when it exists but has wrong value", func() { - moduleStateSecret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "module-state", - Namespace: settings.ModuleNamespace, - }, - Data: map[string][]byte{ - "generic-vmclass-was-ever-created": []byte("false"), - }, - } - - snapshots.GetMock.When(moduleStateSecretSnapshot).Then([]pkg.Snapshot{ - mock.NewSnapshotMock(GinkgoT()).UnmarshalToMock.Set(func(v any) error { - secret, ok := v.(*corev1.Secret) - Expect(ok).To(BeTrue()) - *secret = *moduleStateSecret - return nil - }), - }) - - patchCollector.PatchWithMergeMock.Set(func(obj interface{}, apiVersion, kind, namespace, name string, opts ...pkg.PatchCollectorOption) { - patchData, ok := obj.(map[string]interface{}) - Expect(ok).To(BeTrue()) - Expect(patchData).To(HaveKey("data")) - - data, ok := patchData["data"].(map[string]string) - Expect(ok).To(BeTrue()) - Expect(data).To(HaveKey("generic-vmclass-was-ever-created")) - - Expect(data["generic-vmclass-was-ever-created"]).To(Equal(base64.StdEncoding.EncodeToString([]byte("true")))) - }) - - patchCollector.CreateMock.Optional() - - Expect(Reconcile(context.Background(), newInput())).To(Succeed()) - Expect(patchCollector.PatchWithMergeMock.Calls()).To(HaveLen(1)) - Expect(patchCollector.CreateMock.Calls()).To(HaveLen(0)) - }) - - It("should update module-state secret even when it has correct value", func() { - moduleStateSecret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "module-state", - Namespace: settings.ModuleNamespace, - }, - Data: map[string][]byte{ - "generic-vmclass-was-ever-created": []byte("true"), - }, - } - - snapshots.GetMock.When(moduleStateSecretSnapshot).Then([]pkg.Snapshot{ - mock.NewSnapshotMock(GinkgoT()).UnmarshalToMock.Set(func(v any) error { - secret, ok := v.(*corev1.Secret) - Expect(ok).To(BeTrue()) - *secret = *moduleStateSecret - return nil - }), - }) - - patchCollector.PatchWithMergeMock.Set(func(obj interface{}, apiVersion, kind, namespace, name string, opts ...pkg.PatchCollectorOption) { - patchData, ok := obj.(map[string]interface{}) - Expect(ok).To(BeTrue()) - Expect(patchData).To(HaveKey("data")) - - data, ok := patchData["data"].(map[string]string) - Expect(ok).To(BeTrue()) - Expect(data).To(HaveKey("generic-vmclass-was-ever-created")) - - Expect(data["generic-vmclass-was-ever-created"]).To(Equal(base64.StdEncoding.EncodeToString([]byte("true")))) - }) - - patchCollector.CreateMock.Optional() - - Expect(Reconcile(context.Background(), newInput())).To(Succeed()) - Expect(patchCollector.PatchWithMergeMock.Calls()).To(HaveLen(1)) - Expect(patchCollector.CreateMock.Calls()).To(HaveLen(0)) - }) - }) - - Context("when generic vmclass doesn't exist", func() { - BeforeEach(func() { - snapshots.GetMock.When(vmClassSnapshot).Then([]pkg.Snapshot{}) - }) - - It("should create module-state secret even when vmclass doesn't exist and secret doesn't exist", func() { - snapshots.GetMock.When(moduleStateSecretSnapshot).Then([]pkg.Snapshot{}) - - patchCollector.CreateMock.Set(func(obj interface{}) { - secret, ok := obj.(*corev1.Secret) - Expect(ok).To(BeTrue()) - Expect(secret.Name).To(Equal("module-state")) - Expect(secret.Namespace).To(Equal(settings.ModuleNamespace)) - Expect(secret.Data).To(HaveKey("generic-vmclass-was-ever-created")) - - Expect(string(secret.Data["generic-vmclass-was-ever-created"])).To(Equal("false")) - }) - - patchCollector.PatchWithMergeMock.Optional() - - Expect(Reconcile(context.Background(), newInput())).To(Succeed()) - Expect(patchCollector.CreateMock.Calls()).To(HaveLen(1)) - Expect(patchCollector.PatchWithMergeMock.Calls()).To(HaveLen(0)) - }) - - It("should update module-state secret and keep historical record when vmclass doesn't exist but module-state indicates it was created", func() { - moduleStateSecret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "module-state", - Namespace: settings.ModuleNamespace, - }, - Data: map[string][]byte{ - "generic-vmclass-was-ever-created": []byte("true"), - }, - } - - snapshots.GetMock.When(moduleStateSecretSnapshot).Then([]pkg.Snapshot{ - mock.NewSnapshotMock(GinkgoT()).UnmarshalToMock.Set(func(v any) error { - secret, ok := v.(*corev1.Secret) - Expect(ok).To(BeTrue()) - *secret = *moduleStateSecret - return nil - }), - }) - - patchCollector.PatchWithMergeMock.Set(func(obj interface{}, apiVersion, kind, namespace, name string, opts ...pkg.PatchCollectorOption) { - patchData, ok := obj.(map[string]interface{}) - Expect(ok).To(BeTrue()) - Expect(patchData).To(HaveKey("data")) - - data, ok := patchData["data"].(map[string]string) - Expect(ok).To(BeTrue()) - Expect(data).To(HaveKey("generic-vmclass-created")) - - // Should keep historical record (true) even though VMClass doesn't exist now - Expect(data["generic-vmclass-created"]).To(Equal(base64.StdEncoding.EncodeToString([]byte("true")))) - }) - - patchCollector.CreateMock.Optional() - - Expect(Reconcile(context.Background(), newInput())).To(Succeed()) - Expect(patchCollector.PatchWithMergeMock.Calls()).To(HaveLen(1)) - Expect(patchCollector.CreateMock.Calls()).To(HaveLen(0)) - }) - - It("should update module-state secret when vmclass doesn't exist and secret contains false", func() { - moduleStateSecret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "module-state", - Namespace: settings.ModuleNamespace, - }, - Data: map[string][]byte{ - "generic-vmclass-was-ever-created": []byte("false"), - }, - } - - snapshots.GetMock.When(moduleStateSecretSnapshot).Then([]pkg.Snapshot{ - mock.NewSnapshotMock(GinkgoT()).UnmarshalToMock.Set(func(v any) error { - secret, ok := v.(*corev1.Secret) - Expect(ok).To(BeTrue()) - *secret = *moduleStateSecret - return nil - }), - }) - - patchCollector.PatchWithMergeMock.Set(func(obj interface{}, apiVersion, kind, namespace, name string, opts ...pkg.PatchCollectorOption) { - patchData, ok := obj.(map[string]interface{}) - Expect(ok).To(BeTrue()) - Expect(patchData).To(HaveKey("data")) - - data, ok := patchData["data"].(map[string]string) - Expect(ok).To(BeTrue()) - Expect(data).To(HaveKey("generic-vmclass-was-ever-created")) - - // Should remain false since VMClass doesn't exist - Expect(data["generic-vmclass-was-ever-created"]).To(Equal(base64.StdEncoding.EncodeToString([]byte("false")))) - }) - - patchCollector.CreateMock.Optional() - - Expect(Reconcile(context.Background(), newInput())).To(Succeed()) - Expect(patchCollector.PatchWithMergeMock.Calls()).To(HaveLen(1)) - Expect(patchCollector.CreateMock.Calls()).To(HaveLen(0)) - }) - }) - - Context("state transition logic", func() { - It("should preserve historical true value even when vmclass is deleted and recreated", func() { - // First, simulate that VMClass was created and state recorded as true - moduleStateSecret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "module-state", - Namespace: settings.ModuleNamespace, - }, - Data: map[string][]byte{ - "generic-vmclass-was-ever-created": []byte("true"), - }, - } - - // VMClass doesn't exist now (was deleted) - snapshots.GetMock.When(vmClassSnapshot).Then([]pkg.Snapshot{}) - - snapshots.GetMock.When(moduleStateSecretSnapshot).Then([]pkg.Snapshot{ - mock.NewSnapshotMock(GinkgoT()).UnmarshalToMock.Set(func(v any) error { - secret, ok := v.(*corev1.Secret) - Expect(ok).To(BeTrue()) - *secret = *moduleStateSecret - return nil - }), - }) - - patchCollector.PatchWithMergeMock.Set(func(obj interface{}, apiVersion, kind, namespace, name string, opts ...pkg.PatchCollectorOption) { - patchData, ok := obj.(map[string]interface{}) - Expect(ok).To(BeTrue()) - Expect(patchData).To(HaveKey("data")) - - data, ok := patchData["data"].(map[string]string) - Expect(ok).To(BeTrue()) - Expect(data).To(HaveKey("generic-vmclass-created")) - - // Should preserve historical true value even though VMClass doesn't exist - Expect(data["generic-vmclass-created"]).To(Equal(base64.StdEncoding.EncodeToString([]byte("true")))) - }) - - patchCollector.CreateMock.Optional() - - Expect(Reconcile(context.Background(), newInput())).To(Succeed()) - Expect(patchCollector.PatchWithMergeMock.Calls()).To(HaveLen(1)) - Expect(patchCollector.CreateMock.Calls()).To(HaveLen(0)) - }) - }) -}) diff --git a/openapi/values.yaml b/openapi/values.yaml index a4da0203c7..b93ec2c56c 100644 --- a/openapi/values.yaml +++ b/openapi/values.yaml @@ -138,3 +138,7 @@ properties: properties: error: type: string + moduleState: + type: object + default: {} + additionalProperties: true diff --git a/templates/module-state-secret.yaml b/templates/module-state-secret.yaml new file mode 100644 index 0000000000..9397b7862b --- /dev/null +++ b/templates/module-state-secret.yaml @@ -0,0 +1,14 @@ +{{- if hasKey .Values.virtualization.internal "moduleState" }} +--- +apiVersion: v1 +kind: Secret +type: Opaque +metadata: + name: module-state + namespace: d8-{{ $.Chart.Name }} + {{- include "helm_lib_module_labels" (list . (dict "app" "d8-control-plane-manager")) | nindent 2 }} +data: +{{- range $k, $v := .Values.virtualization.internal.moduleState }} + {{$k}}: {{$v | toJson | b64enc | quote}} +{{- end }} +{{- end }} diff --git a/tools/kubeconform/fixtures/module-values.yaml b/tools/kubeconform/fixtures/module-values.yaml index c817ddfd6b..9bb986b7c2 100644 --- a/tools/kubeconform/fixtures/module-values.yaml +++ b/tools/kubeconform/fixtures/module-values.yaml @@ -394,6 +394,7 @@ virtualization: - 10.0.10.0/24 - 10.0.20.0/24 - 10.0.30.0/24 + moduleState: {} virtConfig: phase: Deployed parallelMigrationsPerCluster: 2