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 10, 2025
1 parent c4d4dca commit 4ff65b3
Show file tree
Hide file tree
Showing 10 changed files with 429 additions and 6 deletions.
12 changes: 12 additions & 0 deletions config/rbac/role.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,18 @@ rules:
- list
- update
- watch
- apiGroups:
- admissionregistration.k8s.io
resources:
- validatingadmissionpolicies
- validatingadmissionpolicybindings
verbs:
- create
- delete
- get
- list
- update
- watch
- apiGroups:
- admissionregistration.k8s.io
resources:
Expand Down
12 changes: 12 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,18 @@ spec:
- list
- update
- watch
- apiGroups:
- admissionregistration.k8s.io
resources:
- validatingadmissionpolicies
- validatingadmissionpolicybindings
verbs:
- create
- delete
- get
- list
- update
- watch
- apiGroups:
- admissionregistration.k8s.io
resources:
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ require (
github.com/blang/semver/v4 v4.0.0
github.com/fsnotify/fsnotify v1.8.0
github.com/go-logr/logr v1.4.2
github.com/google/cel-go v0.22.1
github.com/kubevirt/monitoring/pkg/metrics/parser v0.0.0-20230706095033-373a95665d5a
github.com/machadovilaca/operator-observability v0.0.24
github.com/onsi/ginkgo/v2 v2.22.0
Expand Down Expand Up @@ -61,7 +62,6 @@ require (
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/cel-go v0.22.1 // indirect
github.com/google/gnostic-models v0.6.9 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/google/gofuzz v1.2.0 // indirect
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"
AppComponentVMDeletionProtection AppComponent = "vmDeleteProtection"
)

// 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
98 changes: 98 additions & 0 deletions internal/operands/vm-delete-protection/reconcile.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package vm_delete_protection

import (
"fmt"
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;validatingadmissionpolicybindings,verbs=get;list;create;watch;update;delete

const (
operandName = "vm-delete-protection"
operandComponent = common.AppComponentVMDeletionProtection
virtualMachineDeleteProtectionPolicyName = "kubevirt-vm-deletion-protection"
)

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

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

type VMDeleteProtection struct{}

var _ operands.Operand = &VMDeleteProtection{}

func New() operands.Operand {
if err := checkCelExpression(); err != nil {
panic("Invalid VM delete protection CEL expression")
}
return &VMDeleteProtection{}
}

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

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

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,
newValidatingAdmissionPolicy(),
newValidatingAdmissionPolicyBinding(),
)
}

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

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

foundVAP.Spec = expectedVAP.Spec
}).
StatusFunc(func(resource client.Object) common.ResourceStatus {
status := common.ResourceStatus{}
vap := resource.(*admissionregistrationv1.ValidatingAdmissionPolicy)
if vap.Status.TypeChecking != nil && len(vap.Status.TypeChecking.ExpressionWarnings) != 0 {
msg := fmt.Sprintf("Incorrect VM delete protection VAP CEL expression %v",
vap.Status.TypeChecking)
status.NotAvailable = &msg
status.Degraded = &msg
}
return status
}).
Reconcile()
}

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

foundVAPB.Spec = expectedVAPB.Spec
}).
Reconcile()
}
124 changes: 124 additions & 0 deletions internal/operands/vm-delete-protection/reconcile_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
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"
"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 _ = 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,
},
},
VersionCache: common.VersionCache{},
}
})

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

ExpectResourceExists(newValidatingAdmissionPolicy(), request)
ExpectResourceExists(newValidatingAdmissionPolicyBinding(), request)
})

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)).To(Succeed())

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)).To(Succeed())
Expect(vap.Spec).To(Equal(newValidatingAdmissionPolicy().Spec))

})

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

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

Expect(request.Client.Get(request.Context, key, vapb)).To(Succeed())

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

Expect(request.Client.Update(request.Context, vapb)).To(Succeed())

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

Expect(request.Client.Get(request.Context, key, vapb)).To(Succeed())
Expect(vapb.Spec).To(Equal(newValidatingAdmissionPolicyBinding().Spec))
})

It("should create a valid CEL expression", func() {
err := checkCelExpression()

Expect(err).ToNot(HaveOccurred())
})
})

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

import (
"github.com/google/cel-go/cel"
admissionregistrationv1 "k8s.io/api/admissionregistration/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/utils/ptr"
kubevirt "kubevirt.io/api/core"
kubevirtv1 "kubevirt.io/api/core/v1"
)

const vmDeleteProtectionCELExpression = `(!has(oldObject.metadata.labels) || !(variables.label in oldObject.metadata.labels) || !oldObject.metadata.labels[variables.label].matches('^(true|True)$'))`

func newValidatingAdmissionPolicy() *admissionregistrationv1.ValidatingAdmissionPolicy {
var apiVersions []string
for _, version := range kubevirtv1.ApiSupportedVersions {
apiVersions = append(apiVersions, version.Name)
}

return &admissionregistrationv1.ValidatingAdmissionPolicy{
ObjectMeta: metav1.ObjectMeta{
Name: virtualMachineDeleteProtectionPolicyName,
},
Spec: admissionregistrationv1.ValidatingAdmissionPolicySpec{
FailurePolicy: ptr.To(admissionregistrationv1.Fail),
MatchConstraints: &admissionregistrationv1.MatchResources{
ResourceRules: []admissionregistrationv1.NamedRuleWithOperations{
{
RuleWithOperations: admissionregistrationv1.RuleWithOperations{
Operations: []admissionregistrationv1.OperationType{
admissionregistrationv1.Delete,
},
Rule: admissionregistrationv1.Rule{
APIGroups: []string{kubevirt.GroupName},
APIVersions: apiVersions,
Resources: []string{"virtualmachines"},
},
},
},
},
},
Variables: []admissionregistrationv1.Variable{
{
Name: "label",
Expression: `string('kubevirt.io/vm-delete-protection')`,
},
},
Validations: []admissionregistrationv1.Validation{
{
Expression: vmDeleteProtectionCELExpression,
MessageExpression: `'VirtualMachine ' + string(oldObject.metadata.name) + ' cannot be deleted, remove delete protection'`,
},
},
},
}
}

func newValidatingAdmissionPolicyBinding() *admissionregistrationv1.ValidatingAdmissionPolicyBinding {
return &admissionregistrationv1.ValidatingAdmissionPolicyBinding{
ObjectMeta: metav1.ObjectMeta{
Name: virtualMachineDeleteProtectionPolicyName,
},
Spec: admissionregistrationv1.ValidatingAdmissionPolicyBindingSpec{
PolicyName: virtualMachineDeleteProtectionPolicyName,
ValidationActions: []admissionregistrationv1.ValidationAction{
admissionregistrationv1.Deny,
},
},
}
}

func checkCelExpression() error {
celEnv, err := cel.NewEnv()
if err != nil {
return err
}

_, issues := celEnv.Parse(vmDeleteProtectionCELExpression)

if issues != nil && issues.Err() != nil {
return issues.Err()
}

return nil
}
Loading

0 comments on commit 4ff65b3

Please sign in to comment.