diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index c028e5e2f..7be49ab19 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -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: diff --git a/data/olm-catalog/ssp-operator.clusterserviceversion.yaml b/data/olm-catalog/ssp-operator.clusterserviceversion.yaml index e2d3bcb8a..9b4d0c250 100644 --- a/data/olm-catalog/ssp-operator.clusterserviceversion.yaml +++ b/data/olm-catalog/ssp-operator.clusterserviceversion.yaml @@ -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: diff --git a/internal/common/labels.go b/internal/common/labels.go index e3a87865d..c64f09670 100644 --- a/internal/common/labels.go +++ b/internal/common/labels.go @@ -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 diff --git a/internal/controllers/setup.go b/internal/controllers/setup.go index 7cd593b3c..60b7478e4 100644 --- a/internal/controllers/setup.go +++ b/internal/controllers/setup.go @@ -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" ) @@ -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 { diff --git a/internal/operands/vm-delete-protection/reconcile.go b/internal/operands/vm-delete-protection/reconcile.go new file mode 100644 index 000000000..2bf415248 --- /dev/null +++ b/internal/operands/vm-delete-protection/reconcile.go @@ -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() +} diff --git a/internal/operands/vm-delete-protection/reconcile_test.go b/internal/operands/vm-delete-protection/reconcile_test.go new file mode 100644 index 000000000..fe5713826 --- /dev/null +++ b/internal/operands/vm-delete-protection/reconcile_test.go @@ -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") +} diff --git a/internal/operands/vm-delete-protection/resources.go b/internal/operands/vm-delete-protection/resources.go new file mode 100644 index 000000000..6473cb456 --- /dev/null +++ b/internal/operands/vm-delete-protection/resources.go @@ -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, + }, + }, + } +} diff --git a/tests/vm_deletion_protection_test.go b/tests/vm_deletion_protection_test.go new file mode 100644 index 000000000..7bd908aa6 --- /dev/null +++ b/tests/vm_deletion_protection_test.go @@ -0,0 +1,83 @@ +package tests + +import ( + "fmt" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/util/rand" + "sigs.k8s.io/controller-runtime/pkg/client" + + kubevirtv1 "kubevirt.io/api/core/v1" + "kubevirt.io/ssp-operator/tests/env" +) + +var _ = Describe("VM delete protection", func() { + const deleteProtectionLabel = "kubevirt.io/vm-delete-protection" + + var vm *kubevirtv1.VirtualMachine + + var createVMWithDeleteProtection = func(protected string) { + vmName := fmt.Sprintf("testvmi-%v", rand.String(10)) + vmi := NewMinimalVMIWithNS(strategy.GetNamespace(), vmName) + vm = NewVirtualMachine(vmi) + + vm.Labels = make(map[string]string) + vm.Labels[deleteProtectionLabel] = protected + eventuallyCreateVm(vm) + } + + BeforeEach(func() { + waitUntilDeployed() + }) + + AfterEach(func() { + err := apiClient.Get(ctx, client.ObjectKeyFromObject(vm), vm) + if !errors.IsNotFound(err) { + Eventually(func(g Gomega) error { + err := apiClient.Get(ctx, client.ObjectKeyFromObject(vm), vm) + if err != nil { + return err + } + + vm.Labels[deleteProtectionLabel] = "False" + return apiClient.Update(ctx, vm) + }, env.ShortTimeout(), time.Second).Should(Succeed()) + } + }) + + It("should not allow to delete a VM if the protection is enabled", func() { + createVMWithDeleteProtection("True") + + err := apiClient.Delete(ctx, vm) + + Expect(err).To( + MatchError(ContainSubstring(fmt.Sprintf("VirtualMachine %v cannot be deleted, remove delete protection", vm.Name)))) + }) + + It("should be able to delete a VM if the protection is disabled", func() { + createVMWithDeleteProtection("False") + + Expect(apiClient.Delete(ctx, vm)).To(Succeed()) + waitForDeletion(client.ObjectKeyFromObject(vm), &kubevirtv1.VirtualMachine{}) + }) + + It("should be able to delete a VM if the VM does not have any label", func() { + createVMWithDeleteProtection("True") + + Eventually(func(g Gomega) error { + err := apiClient.Get(ctx, client.ObjectKeyFromObject(vm), vm) + if err != nil { + return err + } + vm.Labels = nil + return apiClient.Update(ctx, vm) + }, env.ShortTimeout(), time.Second).Should(Succeed()) + + Expect(apiClient.Delete(ctx, vm)).To(Succeed()) + waitForDeletion(client.ObjectKeyFromObject(vm), &kubevirtv1.VirtualMachine{}) + }) +})