Skip to content

Commit 578d2f8

Browse files
fl64diafour
andauthored
feat(module): add ability to edit or remove generic vmclass (#1597)
* 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 <[email protected]> Signed-off-by: Ivan Mikheykin <[email protected]> Co-authored-by: Ivan Mikheykin <[email protected]>
1 parent ec8bceb commit 578d2f8

File tree

8 files changed

+728
-137
lines changed

8 files changed

+728
-137
lines changed

images/hooks/cmd/virtualization-module-hooks/register.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,9 @@ import (
2323
_ "hooks/pkg/hooks/discovery-workload-nodes"
2424
_ "hooks/pkg/hooks/drop-openshift-labels"
2525
_ "hooks/pkg/hooks/generate-secret-for-dvcr"
26+
_ "hooks/pkg/hooks/install-vmclass-generic"
2627
_ "hooks/pkg/hooks/migrate-delete-renamed-validation-admission-policy"
2728
_ "hooks/pkg/hooks/migrate-virthandler-kvm-node-labels"
28-
_ "hooks/pkg/hooks/prevent-default-vmclasses-deletion"
2929
_ "hooks/pkg/hooks/tls-certificates-api"
3030
_ "hooks/pkg/hooks/tls-certificates-api-proxy"
3131
_ "hooks/pkg/hooks/tls-certificates-controller"
Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
1+
/*
2+
Copyright 2025 Flant JSC
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package install_vmclass_generic
18+
19+
import (
20+
"context"
21+
"encoding/json"
22+
"fmt"
23+
"strings"
24+
"time"
25+
26+
"hooks/pkg/settings"
27+
28+
"github.com/deckhouse/virtualization/api/core/v1alpha2"
29+
30+
"github.com/deckhouse/module-sdk/pkg"
31+
"github.com/deckhouse/module-sdk/pkg/registry"
32+
33+
corev1 "k8s.io/api/core/v1"
34+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
35+
"k8s.io/utils/ptr"
36+
)
37+
38+
const (
39+
moduleStateSecretSnapshot = "module-state-snapshot"
40+
moduleStateSecretName = "module-state"
41+
42+
vmClassGenericSnapshot = "vmclass-generic-snapshot"
43+
vmClassGenericName = "generic"
44+
45+
vmClassInstallationStateSecretKey = "vmClassGenericInstallation"
46+
vmClassInstallationStateValuesPath = "virtualization.internal.moduleState." + vmClassInstallationStateSecretKey
47+
)
48+
49+
var _ = registry.RegisterFunc(config, Reconcile)
50+
51+
// This hook runs before applying templates (OnBeforeHelm) to drop helm labels
52+
// and make vmclass unmanageable.
53+
var config = &pkg.HookConfig{
54+
OnBeforeHelm: &pkg.OrderedConfig{Order: 5},
55+
Kubernetes: []pkg.KubernetesConfig{
56+
{
57+
Name: moduleStateSecretSnapshot,
58+
APIVersion: "v1",
59+
Kind: "Secret",
60+
JqFilter: `{data}`,
61+
NameSelector: &pkg.NameSelector{
62+
MatchNames: []string{moduleStateSecretName},
63+
},
64+
NamespaceSelector: &pkg.NamespaceSelector{
65+
NameSelector: &pkg.NameSelector{
66+
MatchNames: []string{settings.ModuleNamespace},
67+
},
68+
},
69+
ExecuteHookOnSynchronization: ptr.To(false),
70+
ExecuteHookOnEvents: ptr.To(false),
71+
},
72+
{
73+
Name: vmClassGenericSnapshot,
74+
Kind: v1alpha2.VirtualMachineClassKind,
75+
JqFilter: `{apiVersion, kind, "metadata": ( .metadata | {name, labels, annotations, creationTimestamp} ) }`,
76+
NameSelector: &pkg.NameSelector{
77+
MatchNames: []string{vmClassGenericName},
78+
},
79+
ExecuteHookOnSynchronization: ptr.To(false),
80+
ExecuteHookOnEvents: ptr.To(false),
81+
},
82+
},
83+
84+
Queue: fmt.Sprintf("modules/%s", settings.ModuleName),
85+
}
86+
87+
// Reconcile manages the state of vmclass/generic resource:
88+
//
89+
// - Install a new one if there is no state in the Secret indicating that the vmclass was installed earlier.
90+
// - Removes helm related annotations and labels from existing vmclass/generic (one time operation).
91+
// - No actions performed if user deletes or replaces vmclass/generic.
92+
func Reconcile(_ context.Context, input *pkg.HookInput) error {
93+
moduleState, err := parseVMClassInstallationStateFromSnapshot(input)
94+
if err != nil {
95+
return err
96+
}
97+
98+
// If there is a state for vmclass/generic in the Secret, no changes to vmclass is required.
99+
// Presence of the vmclass is not important, user may delete it and it's ok.
100+
// The important part is to copy state from the Secret into values
101+
// to ensure correct manifest for the Secret template (there may be no state in values, e.g. after deckhouse restart).
102+
if moduleState != nil {
103+
input.Values.Set(vmClassInstallationStateValuesPath, vmClassInstallationState{InstalledAt: moduleState.InstalledAt})
104+
return nil
105+
}
106+
107+
// Corner case: the secret is gone, but the state is present in values.
108+
// Just return without changes to vmclass/generic, so helm will re-create
109+
// the Secret with the module state.
110+
stateInValues := input.Values.Get(vmClassInstallationStateValuesPath)
111+
if stateInValues.Exists() {
112+
return nil
113+
}
114+
115+
vmClassGeneric, err := parseVMClassGenericFromSnapshot(input)
116+
if err != nil {
117+
return err
118+
}
119+
120+
// No state in secret, no state in values, no vmclass/generic.
121+
// Create vmclass/generic and set state in values, as it should be initial module installation.
122+
if vmClassGeneric == nil {
123+
input.Logger.Info("Install VirtualMachineClass/generic")
124+
vmClass := vmClassGenericManifest()
125+
input.PatchCollector.Create(vmClass)
126+
}
127+
// No state in secret, no state in values, but vmclass/generic is present.
128+
// Cleanup metadata if vmclass was created by earlier versions of the module.
129+
if isManagedByModule(vmClassGeneric) {
130+
addPatchesToCleanupMetadata(input, vmClassGeneric)
131+
}
132+
133+
// Set state in values to prevent any further updates to vmclass/generic.
134+
input.Values.Set(vmClassInstallationStateValuesPath, vmClassInstallationState{InstalledAt: time.Now()})
135+
return nil
136+
}
137+
138+
type vmClassInstallationState struct {
139+
InstalledAt time.Time `json:"installedAt"`
140+
}
141+
142+
// parseVMClassInstallationStateFromSnapshot unmarshal vmClassInstallationState from jqFilter result.
143+
func parseVMClassInstallationStateFromSnapshot(input *pkg.HookInput) (*vmClassInstallationState, error) {
144+
snap := input.Snapshots.Get(moduleStateSecretSnapshot)
145+
if len(snap) < 1 {
146+
return nil, nil
147+
}
148+
149+
var ms corev1.Secret
150+
err := snap[0].UnmarshalTo(&ms)
151+
if err != nil {
152+
return nil, err
153+
}
154+
155+
stateRaw := ms.Data[vmClassInstallationStateSecretKey]
156+
if len(stateRaw) == 0 {
157+
return nil, nil
158+
}
159+
160+
var s vmClassInstallationState
161+
err = json.Unmarshal(stateRaw, &s)
162+
if err != nil {
163+
return nil, fmt.Errorf("restore vmclass generic state from secret: %w", err)
164+
}
165+
166+
return &s, nil
167+
}
168+
169+
// parseVMClassGenericFromSnapshot unmarshal ModuleConfig from jqFilter result.
170+
func parseVMClassGenericFromSnapshot(input *pkg.HookInput) (*v1alpha2.VirtualMachineClass, error) {
171+
snap := input.Snapshots.Get(vmClassGenericSnapshot)
172+
if len(snap) < 1 {
173+
return nil, nil
174+
}
175+
176+
var vmclass v1alpha2.VirtualMachineClass
177+
err := snap[0].UnmarshalTo(&vmclass)
178+
if err != nil {
179+
return nil, err
180+
}
181+
return &vmclass, nil
182+
}
183+
184+
// vmClassGenericManifest returns a manifest for 'generic' vmclass
185+
// that should work for VM on every Node in cluster.
186+
func vmClassGenericManifest() *v1alpha2.VirtualMachineClass {
187+
return &v1alpha2.VirtualMachineClass{
188+
TypeMeta: metav1.TypeMeta{
189+
APIVersion: v1alpha2.SchemeGroupVersion.String(),
190+
Kind: v1alpha2.VirtualMachineClassKind,
191+
},
192+
ObjectMeta: metav1.ObjectMeta{
193+
Name: vmClassGenericName,
194+
Labels: map[string]string{
195+
"app": "virtualization-controller",
196+
"module": settings.ModuleName,
197+
},
198+
},
199+
Spec: v1alpha2.VirtualMachineClassSpec{
200+
CPU: v1alpha2.CPU{
201+
Type: v1alpha2.CPUTypeModel,
202+
Model: "Nehalem",
203+
},
204+
SizingPolicies: []v1alpha2.SizingPolicy{
205+
{
206+
Cores: &v1alpha2.SizingPolicyCores{
207+
Min: 1,
208+
Max: 4,
209+
},
210+
DedicatedCores: []bool{false},
211+
CoreFractions: []v1alpha2.CoreFractionValue{5, 10, 20, 50, 100},
212+
},
213+
{
214+
Cores: &v1alpha2.SizingPolicyCores{
215+
Min: 5,
216+
Max: 8,
217+
},
218+
DedicatedCores: []bool{false},
219+
CoreFractions: []v1alpha2.CoreFractionValue{20, 50, 100},
220+
},
221+
{
222+
Cores: &v1alpha2.SizingPolicyCores{
223+
Min: 9,
224+
Max: 16,
225+
},
226+
DedicatedCores: []bool{true, false},
227+
CoreFractions: []v1alpha2.CoreFractionValue{50, 100},
228+
},
229+
{
230+
Cores: &v1alpha2.SizingPolicyCores{
231+
Min: 17,
232+
Max: 1024,
233+
},
234+
DedicatedCores: []bool{true, false},
235+
CoreFractions: []v1alpha2.CoreFractionValue{100},
236+
},
237+
},
238+
},
239+
}
240+
}
241+
242+
// isManagedByModule checks if vmclass has all labels that module set when installing vmclass.
243+
func isManagedByModule(vmClass *v1alpha2.VirtualMachineClass) bool {
244+
if vmClass == nil {
245+
return false
246+
}
247+
248+
expectLabels := vmClassGenericManifest().Labels
249+
250+
for label, expectValue := range expectLabels {
251+
actualValue, exists := vmClass.Labels[label]
252+
if !exists || actualValue != expectValue {
253+
return false
254+
}
255+
}
256+
return true
257+
}
258+
259+
const (
260+
heritageLabel = "heritage"
261+
helmManagedByLabel = "app.kubernetes.io/managed-by"
262+
helmReleaseNameAnno = "meta.helm.sh/release-name"
263+
helmReleaseNamespaceAnno = "meta.helm.sh/release-namespace"
264+
helmKeepResourceAnno = "helm.sh/resource-policy"
265+
)
266+
267+
// addPatchesToCleanupMetadata fills patch collector with patches if vmclass metadata
268+
// should be cleaned.
269+
func addPatchesToCleanupMetadata(input *pkg.HookInput, vmClass *v1alpha2.VirtualMachineClass) {
270+
var patches []map[string]interface{}
271+
272+
labelNames := []string{
273+
heritageLabel,
274+
helmManagedByLabel,
275+
}
276+
for _, labelName := range labelNames {
277+
if _, exists := vmClass.Labels[labelName]; exists {
278+
patches = append(patches, map[string]interface{}{
279+
"op": "remove",
280+
"path": fmt.Sprintf("/metadata/labels/%s", jsonPatchEscape(labelName)),
281+
"value": nil,
282+
})
283+
}
284+
}
285+
286+
// Ensure "keep resource" annotation on vmclass/generic, so Helm will keep it
287+
// in the cluster even that we've deleted its manifest from templates.
288+
if _, exists := vmClass.Annotations[helmKeepResourceAnno]; !exists {
289+
patches = append(patches, map[string]interface{}{
290+
"op": "add",
291+
"path": fmt.Sprintf("/metadata/annotations/%s", jsonPatchEscape(helmKeepResourceAnno)),
292+
"value": nil,
293+
})
294+
}
295+
296+
annoNames := []string{
297+
helmReleaseNameAnno,
298+
helmReleaseNamespaceAnno,
299+
}
300+
for _, annoName := range annoNames {
301+
if _, exists := vmClass.Annotations[annoName]; exists {
302+
patches = append(patches, map[string]interface{}{
303+
"op": "remove",
304+
"path": fmt.Sprintf("/metadata/annotations/%s", jsonPatchEscape(annoName)),
305+
"value": nil,
306+
})
307+
}
308+
}
309+
310+
if len(patches) == 0 {
311+
return
312+
}
313+
314+
input.Logger.Info("Patch VirtualMachineClass/generic: remove Helm labels and annotations")
315+
input.PatchCollector.PatchWithJSON(
316+
patches,
317+
vmClass.APIVersion,
318+
vmClass.Kind,
319+
vmClass.Namespace,
320+
vmClass.Name,
321+
)
322+
}
323+
324+
func jsonPatchEscape(s string) string {
325+
return strings.NewReplacer("~", "~0", "/", "~1").Replace(s)
326+
}

0 commit comments

Comments
 (0)