diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index c028e5e2f..fb8c59650 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -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: diff --git a/data/olm-catalog/ssp-operator.clusterserviceversion.yaml b/data/olm-catalog/ssp-operator.clusterserviceversion.yaml index e2d3bcb8a..35bca03ae 100644 --- a/data/olm-catalog/ssp-operator.clusterserviceversion.yaml +++ b/data/olm-catalog/ssp-operator.clusterserviceversion.yaml @@ -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: diff --git a/go.mod b/go.mod index 3d68e5096..a413942f6 100644 --- a/go.mod +++ b/go.mod @@ -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 @@ -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 diff --git a/internal/common/labels.go b/internal/common/labels.go index e3a87865d..fe8322db2 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" + AppComponentVMDeletionProtection AppComponent = "vmDeleteProtection" ) // 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..04ea0ee92 --- /dev/null +++ b/internal/operands/vm-delete-protection/reconcile.go @@ -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() +} 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..f348848e7 --- /dev/null +++ b/internal/operands/vm-delete-protection/reconcile_test.go @@ -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") +} diff --git a/internal/operands/vm-delete-protection/resources.go b/internal/operands/vm-delete-protection/resources.go new file mode 100644 index 000000000..cda4c2ae8 --- /dev/null +++ b/internal/operands/vm-delete-protection/resources.go @@ -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 +} diff --git a/tests/cleanup_test.go b/tests/cleanup_test.go index 35ab8c65e..8614c8f4a 100644 --- a/tests/cleanup_test.go +++ b/tests/cleanup_test.go @@ -18,6 +18,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" ) var _ = Describe("Cleanup", func() { @@ -40,6 +41,7 @@ var _ = Describe("Cleanup", func() { template_validator.WatchClusterTypes, vm_console_proxy.WatchClusterTypes, tekton_cleanup.WatchClusterTypes, + vm_delete_protection.WatchClusterTypes, } { allWatchTypes = append(allWatchTypes, f()...) } diff --git a/tests/vm_delete_protection_test.go b/tests/vm_delete_protection_test.go new file mode 100644 index 000000000..be403e308 --- /dev/null +++ b/tests/vm_delete_protection_test.go @@ -0,0 +1,87 @@ +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" + kubevirtv1 "kubevirt.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + "kubevirt.io/ssp-operator/tests/env" +) + +const deleteProtectionLabel = "kubevirt.io/vm-delete-protection" + +var _ = Describe("VM delete protection", func() { + + var vm *kubevirtv1.VirtualMachine + + BeforeEach(func() { + waitUntilDeployed() + }) + + AfterEach(func() { + err := apiClient.Get(ctx, client.ObjectKeyFromObject(vm), vm) + if !errors.IsNotFound(err) { + Eventually(func() 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()) + + Expect(apiClient.Delete(ctx, vm)).To(Succeed()) + waitForDeletion(client.ObjectKeyFromObject(vm), &kubevirtv1.VirtualMachine{}) + } + }) + + It("should not allow to delete a VM if the protection is enabled", func() { + vm = createVMWithDeleteProtection("True") + + Expect(apiClient.Delete(ctx, vm)).To( + MatchError(ContainSubstring("VirtualMachine %v cannot be deleted, remove delete protection", vm.Name))) + }) + + It("should be able to delete a VM if the protection is disabled", func() { + vm = 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() { + vm = createVMWithDeleteProtection("True") + + Eventually(func() 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{}) + }) +}) + +func createVMWithDeleteProtection(protected string) *kubevirtv1.VirtualMachine { + 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) + + return vm +}