Skip to content

Commit

Permalink
feat: Add VM delete protection
Browse files Browse the repository at this point in the history
It adds the ability to protect VirtualMachine objects from being
deleted. If the label `kubevirt.io/vm-delete-protection` is set to
`True`, any attempt to delete the VM will be rejected by a VAP policy.

Signed-off-by: Javier Cano Cano <[email protected]>
  • Loading branch information
jcanocan committed Jan 9, 2025
1 parent c4d4dca commit 03ff41f
Show file tree
Hide file tree
Showing 8 changed files with 433 additions and 5 deletions.
20 changes: 20 additions & 0 deletions config/rbac/role.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,26 @@ rules:
- list
- update
- watch
- apiGroups:
- admissionregistration.k8s.io
resources:
- validatingadmissionpolicies
verbs:
- create
- get
- list
- update
- watch
- apiGroups:
- admissionregistration.k8s.io
resources:
- validatingadmissionpolicybindings
verbs:
- create
- get
- list
- update
- watch
- apiGroups:
- admissionregistration.k8s.io
resources:
Expand Down
20 changes: 20 additions & 0 deletions data/olm-catalog/ssp-operator.clusterserviceversion.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,26 @@ spec:
- list
- update
- watch
- apiGroups:
- admissionregistration.k8s.io
resources:
- validatingadmissionpolicies
verbs:
- create
- get
- list
- update
- watch
- apiGroups:
- admissionregistration.k8s.io
resources:
- validatingadmissionpolicybindings
verbs:
- create
- get
- list
- update
- watch
- apiGroups:
- admissionregistration.k8s.io
resources:
Expand Down
11 changes: 6 additions & 5 deletions internal/common/labels.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,12 @@ func (a AppComponent) String() string {
}

const (
AppComponentMonitoring AppComponent = "monitoring"
AppComponentSchedule AppComponent = "schedule"
AppComponentTemplating AppComponent = "templating"
AppComponentTektonPipelines AppComponent = "tektonPipelines"
AppComponentTektonTasks AppComponent = "tektonTasks"
AppComponentMonitoring AppComponent = "monitoring"
AppComponentSchedule AppComponent = "schedule"
AppComponentTemplating AppComponent = "templating"
AppComponentTektonPipelines AppComponent = "tektonPipelines"
AppComponentTektonTasks AppComponent = "tektonTasks"
AppComponetVMDeletionProtection = "VMDeletionProtection"
)

// AddAppLabels to the provided obj
Expand Down
2 changes: 2 additions & 0 deletions internal/controllers/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
tekton_cleanup "kubevirt.io/ssp-operator/internal/operands/tekton-cleanup"
template_validator "kubevirt.io/ssp-operator/internal/operands/template-validator"
vm_console_proxy "kubevirt.io/ssp-operator/internal/operands/vm-console-proxy"
vm_delete_protection "kubevirt.io/ssp-operator/internal/operands/vm-delete-protection"
template_bundle "kubevirt.io/ssp-operator/internal/template-bundle"
vm_console_proxy_bundle "kubevirt.io/ssp-operator/internal/vm-console-proxy-bundle"
)
Expand Down Expand Up @@ -69,6 +70,7 @@ func CreateControllers(ctx context.Context, apiReader client.Reader) ([]Controll
common_instancetypes.New(),
data_sources.New(templatesBundle.DataSources),
tekton_cleanup.New(),
vm_delete_protection.New(),
}

if runningOnOpenShift {
Expand Down
82 changes: 82 additions & 0 deletions internal/operands/vm-delete-protection/reconcile.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package vm_delete_protection

import (
admissionregistrationv1 "k8s.io/api/admissionregistration/v1"
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
"kubevirt.io/ssp-operator/internal/common"
"kubevirt.io/ssp-operator/internal/operands"
"sigs.k8s.io/controller-runtime/pkg/client"
)

// Define RBAC rules needed by this operand:
// +kubebuilder:rbac:groups=admissionregistration.k8s.io,resources=validatingadmissionpolicies,verbs=get;list;create;watch;update
// +kubebuilder:rbac:groups=admissionregistration.k8s.io,resources=validatingadmissionpolicybindings,verbs=get;list;create;watch;update

const (
operandName = "vm-delete-protection"
operandComponent = common.AppComponetVMDeletionProtection
virtualMachineDeleteProtectionPolicyName = "ssp-vm-deletion-protection-policy"
virtualMachineDeleteProtectionPolicyBindingName = "ssp-vm-deletion-protection-binding"
)

func init() {
utilruntime.Must(admissionregistrationv1.AddToScheme(common.Scheme))
}

type VMDeleteProtection struct{}

var _ operands.Operand = &VMDeleteProtection{}

func New() operands.Operand { return &VMDeleteProtection{} }

func (v *VMDeleteProtection) WatchTypes() []operands.WatchType {
return nil
}

func (v *VMDeleteProtection) WatchClusterTypes() []operands.WatchType {
return []operands.WatchType{
{Object: &admissionregistrationv1.ValidatingAdmissionPolicy{}},
{Object: &admissionregistrationv1.ValidatingAdmissionPolicyBinding{}},
}
}

func (v *VMDeleteProtection) Reconcile(request *common.Request) ([]common.ReconcileResult, error) {
return common.CollectResourceStatus(request,
reconcileVAP,
reconcileVAPB,
)
}

func (v *VMDeleteProtection) Cleanup(request *common.Request) ([]common.CleanupResult, error) {
return common.DeleteAll(request)
}

func (v *VMDeleteProtection) Name() string { return operandName }

func reconcileVAP(request *common.Request) (common.ReconcileResult, error) {
return common.CreateOrUpdate(request).
ClusterResource(NewVMDeleteProtectionValidatingAdmissionPolicy()).
WithAppLabels(operandName, operandComponent).
UpdateFunc(func(expected, found client.Object) {
foundVAP := found.(*admissionregistrationv1.ValidatingAdmissionPolicy)
expectedVAP := expected.(*admissionregistrationv1.ValidatingAdmissionPolicy)

foundVAP.Labels = expectedVAP.Labels
foundVAP.Spec = expectedVAP.Spec
}).
Reconcile()
}

func reconcileVAPB(request *common.Request) (common.ReconcileResult, error) {
return common.CreateOrUpdate(request).
ClusterResource(NewVMDeleteProtectionValidatingAdmissionPolicyBinding()).
WithAppLabels(operandName, operandComponent).
UpdateFunc(func(expected, found client.Object) {
foundVAPB := found.(*admissionregistrationv1.ValidatingAdmissionPolicyBinding)
expectedVAPB := expected.(*admissionregistrationv1.ValidatingAdmissionPolicyBinding)

foundVAPB.Labels = expectedVAPB.Labels
foundVAPB.Spec = expectedVAPB.Spec
}).
Reconcile()
}
151 changes: 151 additions & 0 deletions internal/operands/vm-delete-protection/reconcile_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
package vm_delete_protection

import (
"context"
admissionregistrationv1 "k8s.io/api/admissionregistration/v1"
"testing"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"

metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
logf "sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/reconcile"

ssp "kubevirt.io/ssp-operator/api/v1beta2"
"kubevirt.io/ssp-operator/internal/common"
. "kubevirt.io/ssp-operator/internal/test-utils"
)

var log = logf.Log.WithName("vm_delete_protection_operand")

var _ = Describe("VM delete protection operand", func() {
const (
namespace = "kubevirt"
name = "test-ssp"
)

var (
request common.Request
operand = New()
)

BeforeEach(func() {
client := fake.NewClientBuilder().WithScheme(common.Scheme).Build()
request = common.Request{
Request: reconcile.Request{
NamespacedName: types.NamespacedName{
Namespace: namespace,
Name: name,
},
},
Client: client,
Context: context.Background(),
Instance: &ssp.SSP{
TypeMeta: metav1.TypeMeta{
Kind: "SSP",
APIVersion: ssp.GroupVersion.String(),
},
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: namespace,
},
},
Logger: log,
VersionCache: common.VersionCache{},
}
})

It("should create VM deletion protection resources", func() {
_, err := operand.Reconcile(&request)
Expect(err).ToNot(HaveOccurred())

ExpectResourceExists(NewVMDeleteProtectionValidatingAdmissionPolicy(), request)
ExpectResourceExists(NewVMDeleteProtectionValidatingAdmissionPolicyBinding(), request)
})

DescribeTable("should update labels if changed", func(obj client.Object, objName string) {
testLabel := map[string]string{
"test-label": "test-value",
}

_, err := operand.Reconcile(&request)
Expect(err).ToNot(HaveOccurred())

key := client.ObjectKey{Name: objName}

Expect(request.Client.Get(request.Context, key, obj)).ToNot(HaveOccurred())

obj.SetLabels(testLabel)

Expect(request.Client.Update(request.Context, obj)).ToNot(HaveOccurred())

_, err = operand.Reconcile(&request)
Expect(err).ToNot(HaveOccurred())

Expect(request.Client.Get(request.Context, key, obj)).ToNot(HaveOccurred())
Expect(obj.GetLabels()).ToNot(HaveKey(testLabel))
},
Entry("VAP",
&admissionregistrationv1.ValidatingAdmissionPolicy{}, virtualMachineDeleteProtectionPolicyName),
Entry("VAPB",
&admissionregistrationv1.ValidatingAdmissionPolicyBinding{}, virtualMachineDeleteProtectionPolicyBindingName),
)

It("should update VAP spec if changed", func() {
_, err := operand.Reconcile(&request)
Expect(err).ToNot(HaveOccurred())

vap := &admissionregistrationv1.ValidatingAdmissionPolicy{}
key := client.ObjectKey{Name: virtualMachineDeleteProtectionPolicyName}

Expect(request.Client.Get(request.Context, key, vap)).ToNot(HaveOccurred())

vap.Spec.Variables = []admissionregistrationv1.Variable{
{
Name: "test-variable",
Expression: `test-expression`,
},
}

Expect(request.Client.Update(request.Context, vap)).ToNot(HaveOccurred())

_, err = operand.Reconcile(&request)
Expect(err).ToNot(HaveOccurred())

Expect(request.Client.Get(request.Context, key, vap)).ToNot(HaveOccurred())
Expect(vap.Spec).To(Equal(NewVMDeleteProtectionValidatingAdmissionPolicy().Spec))

})

It("should update VAPB spec if changed", func() {
_, err := operand.Reconcile(&request)
Expect(err).ToNot(HaveOccurred())

vapb := &admissionregistrationv1.ValidatingAdmissionPolicyBinding{}
key := client.ObjectKey{Name: virtualMachineDeleteProtectionPolicyBindingName}

Expect(request.Client.Get(request.Context, key, vapb)).ToNot(HaveOccurred())

vapb.Spec.ValidationActions = []admissionregistrationv1.ValidationAction{
admissionregistrationv1.Warn,
}

Expect(request.Client.Update(request.Context, vapb)).ToNot(HaveOccurred())

_, err = operand.Reconcile(&request)
Expect(err).ToNot(HaveOccurred())

Expect(request.Client.Get(request.Context, key, vapb)).ToNot(HaveOccurred())
Expect(vapb.Spec).To(Equal(NewVMDeleteProtectionValidatingAdmissionPolicyBinding().Spec))
})

})

func TestVMDeleteProtection(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "VM Delete Protection Suite")
}
69 changes: 69 additions & 0 deletions internal/operands/vm-delete-protection/resources.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package vm_delete_protection

import (
admissionregistrationv1 "k8s.io/api/admissionregistration/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

func NewVMDeleteProtectionValidatingAdmissionPolicy() *admissionregistrationv1.ValidatingAdmissionPolicy {
failPolicy := admissionregistrationv1.Fail

return &admissionregistrationv1.ValidatingAdmissionPolicy{
TypeMeta: metav1.TypeMeta{
APIVersion: "v1",
Kind: "ValidatingAdmissionPolicy",
},
ObjectMeta: metav1.ObjectMeta{
Name: virtualMachineDeleteProtectionPolicyName,
},
Spec: admissionregistrationv1.ValidatingAdmissionPolicySpec{
FailurePolicy: &failPolicy,
MatchConstraints: &admissionregistrationv1.MatchResources{
ResourceRules: []admissionregistrationv1.NamedRuleWithOperations{
{
RuleWithOperations: admissionregistrationv1.RuleWithOperations{
Operations: []admissionregistrationv1.OperationType{
admissionregistrationv1.Delete,
},
Rule: admissionregistrationv1.Rule{
APIGroups: []string{"kubevirt.io"},
APIVersions: []string{"*"},
Resources: []string{"virtualmachines"},
},
},
},
},
},
Variables: []admissionregistrationv1.Variable{
{
Name: "label",
Expression: `string('kubevirt.io/vm-delete-protection')`,
},
},
Validations: []admissionregistrationv1.Validation{
{
Expression: `(!has(oldObject.metadata.labels) || !(variables.label in oldObject.metadata.labels) || !oldObject.metadata.labels[variables.label].matches('^(true|True)$'))`,
MessageExpression: `'VirtualMachine ' + string(oldObject.metadata.name) + ' cannot be deleted, remove delete protection'`,
},
},
},
}
}

func NewVMDeleteProtectionValidatingAdmissionPolicyBinding() *admissionregistrationv1.ValidatingAdmissionPolicyBinding {
return &admissionregistrationv1.ValidatingAdmissionPolicyBinding{
TypeMeta: metav1.TypeMeta{
APIVersion: "v1",
Kind: "ValidatingAdmissionPolicyBinding",
},
ObjectMeta: metav1.ObjectMeta{
Name: virtualMachineDeleteProtectionPolicyBindingName,
},
Spec: admissionregistrationv1.ValidatingAdmissionPolicyBindingSpec{
PolicyName: virtualMachineDeleteProtectionPolicyName,
ValidationActions: []admissionregistrationv1.ValidationAction{
admissionregistrationv1.Deny,
},
},
}
}
Loading

0 comments on commit 03ff41f

Please sign in to comment.