diff --git a/images/hooks/cmd/virtualization-module-hooks/register.go b/images/hooks/cmd/virtualization-module-hooks/register.go index 39c8b95a04..425845d7ea 100644 --- a/images/hooks/cmd/virtualization-module-hooks/register.go +++ b/images/hooks/cmd/virtualization-module-hooks/register.go @@ -23,9 +23,9 @@ import ( _ "hooks/pkg/hooks/discovery-workload-nodes" _ "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/prevent-default-vmclasses-deletion" _ "hooks/pkg/hooks/tls-certificates-api" _ "hooks/pkg/hooks/tls-certificates-api-proxy" _ "hooks/pkg/hooks/tls-certificates-controller" 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/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/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/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] 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