diff --git a/cmd/lineage-controller-webhook/main.go b/cmd/lineage-controller-webhook/main.go index ffe0942b..5f33ee1b 100644 --- a/cmd/lineage-controller-webhook/main.go +++ b/cmd/lineage-controller-webhook/main.go @@ -160,6 +160,10 @@ func main() { os.Exit(1) } + deletionProtectionWebhook := &lcw.DeletionProtectionWebhook{} + mgr.GetWebhookServer().Register("/validate-deletion", &webhook.Admission{Handler: deletionProtectionWebhook}) + setupLog.Info("registered deletion protection webhook at /validate-deletion") + // +kubebuilder:scaffold:builder if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { diff --git a/internal/crdinstall/install.go b/internal/crdinstall/install.go index 5143f2e6..516a63fa 100644 --- a/internal/crdinstall/install.go +++ b/internal/crdinstall/install.go @@ -89,6 +89,16 @@ func Install(ctx context.Context, k8sClient client.Client, writeEmbeddedManifest } } + // Add deletion protection label to all CRDs + for _, obj := range objects { + labels := obj.GetLabels() + if labels == nil { + labels = make(map[string]string) + } + labels["cozystack.io/deletion-protected"] = "true" + obj.SetLabels(labels) + } + logger.Info("Applying Cozystack CRDs", "count", len(objects)) for _, obj := range objects { patchOptions := &client.PatchOptions{ diff --git a/internal/lineagecontrollerwebhook/validate_deletion.go b/internal/lineagecontrollerwebhook/validate_deletion.go new file mode 100644 index 00000000..5ebe4d29 --- /dev/null +++ b/internal/lineagecontrollerwebhook/validate_deletion.go @@ -0,0 +1,106 @@ +/* +Copyright 2025 The Cozystack Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package lineagecontrollerwebhook + +import ( + "context" + "fmt" + "net/http" + + admissionv1 "k8s.io/api/admission/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +const ( + deletionProtectedLabel = "cozystack.io/deletion-protected" +) + +// DeletionProtectionWebhook denies DELETE requests on resources that carry +// the cozystack.io/deletion-protected label. The objectSelector on the +// ValidatingWebhookConfiguration ensures this handler is only called for +// labeled resources. +type DeletionProtectionWebhook struct{} + +func (h *DeletionProtectionWebhook) Handle(ctx context.Context, req admission.Request) admission.Response { + logger := log.FromContext(ctx).WithValues( + "kind", req.Kind.Kind, + "namespace", req.Namespace, + "name", req.Name, + "operation", req.Operation, + ) + + if req.Operation != admissionv1.Delete { + return admission.Allowed("not a DELETE operation") + } + + var identifier string + if req.Namespace != "" { + identifier = fmt.Sprintf("%s %s/%s", req.Kind.Kind, req.Namespace, req.Name) + } else { + identifier = fmt.Sprintf("%s %s", req.Kind.Kind, req.Name) + } + + msg := fmt.Sprintf( + "deletion of %s is protected by cozystack. "+ + "To force-delete, first remove the label: "+ + "kubectl label %s %s %s-", + identifier, + kindToResourceArg(req.Kind.Kind, req.Namespace), + req.Name, + deletionProtectedLabel, + ) + + logger.Info("DENIED deletion of protected resource", "resource", identifier) + + return admission.Response{ + AdmissionResponse: admissionv1.AdmissionResponse{ + Allowed: false, + Result: &metav1.Status{ + Status: metav1.StatusFailure, + Message: msg, + Reason: metav1.StatusReasonForbidden, + Code: http.StatusForbidden, + }, + }, + } +} + +func kindToResourceArg(kind, namespace string) string { + switch kind { + case "Namespace": + return "namespace" + case "ConfigMap": + return "configmap -n " + namespace + case "HelmRelease": + return "helmrelease.helm.toolkit.fluxcd.io -n " + namespace + case "CustomResourceDefinition": + return "crd" + case "LinstorCluster": + return "linstorcluster.piraeus.io" + case "ClusterIssuer": + return "clusterissuer.cert-manager.io" + case "OCIRepository": + return "ocirepository.source.toolkit.fluxcd.io -n " + namespace + default: + if namespace != "" { + return kind + " -n " + namespace + } + return kind + } +} diff --git a/internal/lineagecontrollerwebhook/validate_deletion_test.go b/internal/lineagecontrollerwebhook/validate_deletion_test.go new file mode 100644 index 00000000..ee4b8ed1 --- /dev/null +++ b/internal/lineagecontrollerwebhook/validate_deletion_test.go @@ -0,0 +1,164 @@ +/* +Copyright 2025 The Cozystack Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package lineagecontrollerwebhook + +import ( + "context" + "net/http" + "testing" + + admissionv1 "k8s.io/api/admission/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +func TestDeletionProtectionWebhook_Handle(t *testing.T) { + log.SetLogger(zap.New(zap.UseDevMode(true))) + ctx := context.Background() + + handler := &DeletionProtectionWebhook{} + + tests := []struct { + name string + req admission.Request + wantAllowed bool + wantCode int32 + }{ + { + name: "deny DELETE on protected namespace", + req: admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Operation: admissionv1.Delete, + Name: "tenant-root", + Kind: metav1.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "Namespace", + }, + }, + }, + wantAllowed: false, + wantCode: http.StatusForbidden, + }, + { + name: "deny DELETE on protected HelmRelease", + req: admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Operation: admissionv1.Delete, + Name: "tenant-root", + Namespace: "tenant-root", + Kind: metav1.GroupVersionKind{ + Group: "helm.toolkit.fluxcd.io", + Version: "v2", + Kind: "HelmRelease", + }, + }, + }, + wantAllowed: false, + wantCode: http.StatusForbidden, + }, + { + name: "deny DELETE on protected CRD", + req: admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Operation: admissionv1.Delete, + Name: "packages.cozystack.io", + Kind: metav1.GroupVersionKind{ + Group: "apiextensions.k8s.io", + Version: "v1", + Kind: "CustomResourceDefinition", + }, + }, + }, + wantAllowed: false, + wantCode: http.StatusForbidden, + }, + { + name: "deny DELETE on protected LinstorCluster", + req: admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Operation: admissionv1.Delete, + Name: "linstorcluster", + Kind: metav1.GroupVersionKind{ + Group: "piraeus.io", + Version: "v1", + Kind: "LinstorCluster", + }, + }, + }, + wantAllowed: false, + wantCode: http.StatusForbidden, + }, + { + name: "allow non-DELETE operation", + req: admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Operation: admissionv1.Update, + Name: "tenant-root", + Kind: metav1.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "Namespace", + }, + }, + }, + wantAllowed: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp := handler.Handle(ctx, tt.req) + + if resp.Allowed != tt.wantAllowed { + t.Errorf("Allowed = %v, want %v", resp.Allowed, tt.wantAllowed) + } + if !tt.wantAllowed && resp.Result != nil && resp.Result.Code != tt.wantCode { + t.Errorf("Code = %d, want %d", resp.Result.Code, tt.wantCode) + } + }) + } +} + +func TestKindToResourceArg(t *testing.T) { + tests := []struct { + kind string + namespace string + want string + }{ + {"Namespace", "", "namespace"}, + {"ConfigMap", "cozy-system", "configmap -n cozy-system"}, + {"HelmRelease", "tenant-root", "helmrelease.helm.toolkit.fluxcd.io -n tenant-root"}, + {"CustomResourceDefinition", "", "crd"}, + {"LinstorCluster", "", "linstorcluster.piraeus.io"}, + {"ClusterIssuer", "", "clusterissuer.cert-manager.io"}, + {"OCIRepository", "cozy-system", "ocirepository.source.toolkit.fluxcd.io -n cozy-system"}, + {"Unknown", "ns", "Unknown -n ns"}, + {"Unknown", "", "Unknown"}, + } + + for _, tt := range tests { + t.Run(tt.kind, func(t *testing.T) { + got := kindToResourceArg(tt.kind, tt.namespace) + if got != tt.want { + t.Errorf("kindToResourceArg(%q, %q) = %q, want %q", tt.kind, tt.namespace, got, tt.want) + } + }) + } +} diff --git a/packages/core/installer/templates/cozystack-operator.yaml b/packages/core/installer/templates/cozystack-operator.yaml index aded0995..a9877044 100644 --- a/packages/core/installer/templates/cozystack-operator.yaml +++ b/packages/core/installer/templates/cozystack-operator.yaml @@ -8,6 +8,7 @@ kind: Namespace metadata: name: cozy-system labels: + cozystack.io/deletion-protected: "true" cozystack.io/system: "true" pod-security.kubernetes.io/enforce: privileged annotations: diff --git a/packages/core/platform/templates/cozystack-version.yaml b/packages/core/platform/templates/cozystack-version.yaml index 09b845c2..1f93a1cd 100644 --- a/packages/core/platform/templates/cozystack-version.yaml +++ b/packages/core/platform/templates/cozystack-version.yaml @@ -6,6 +6,8 @@ kind: ConfigMap metadata: name: cozystack-version namespace: {{ .Release.Namespace }} + labels: + cozystack.io/deletion-protected: "true" annotations: helm.sh/resource-policy: keep data: diff --git a/packages/core/platform/templates/repository.yaml b/packages/core/platform/templates/repository.yaml index 9bd9f5a1..882a0a90 100644 --- a/packages/core/platform/templates/repository.yaml +++ b/packages/core/platform/templates/repository.yaml @@ -13,5 +13,7 @@ kind: {{ $kind }} metadata: name: cozystack-packages namespace: cozy-system + labels: + cozystack.io/deletion-protected: "true" spec: {{- $sourceRepo.spec | toYaml | nindent 2 }} diff --git a/packages/system/cert-manager-issuers/templates/cluster-issuers.yaml b/packages/system/cert-manager-issuers/templates/cluster-issuers.yaml index 442f1fb3..f3457b8e 100644 --- a/packages/system/cert-manager-issuers/templates/cluster-issuers.yaml +++ b/packages/system/cert-manager-issuers/templates/cluster-issuers.yaml @@ -1,9 +1,11 @@ {{- $solver := (index .Values._cluster "solver") | default "http01" }} -apiVersion: cert-manager.io/v1 -kind: ClusterIssuer -metadata: - name: letsencrypt-prod +apiVersion: cert-manager.io/v1 +kind: ClusterIssuer +metadata: + name: letsencrypt-prod + labels: + cozystack.io/deletion-protected: "true" spec: acme: privateKeySecretRef: @@ -24,10 +26,12 @@ spec: --- -apiVersion: cert-manager.io/v1 -kind: ClusterIssuer -metadata: - name: letsencrypt-stage +apiVersion: cert-manager.io/v1 +kind: ClusterIssuer +metadata: + name: letsencrypt-stage + labels: + cozystack.io/deletion-protected: "true" spec: acme: privateKeySecretRef: @@ -48,9 +52,11 @@ spec: --- -apiVersion: cert-manager.io/v1 -kind: ClusterIssuer -metadata: - name: selfsigned-cluster-issuer +apiVersion: cert-manager.io/v1 +kind: ClusterIssuer +metadata: + name: selfsigned-cluster-issuer + labels: + cozystack.io/deletion-protected: "true" spec: selfSigned: {} diff --git a/packages/system/cozystack-basics/templates/tenant-root.yaml b/packages/system/cozystack-basics/templates/tenant-root.yaml index 93f5129e..1caf909b 100644 --- a/packages/system/cozystack-basics/templates/tenant-root.yaml +++ b/packages/system/cozystack-basics/templates/tenant-root.yaml @@ -3,6 +3,8 @@ apiVersion: v1 kind: Namespace metadata: name: tenant-root + labels: + cozystack.io/deletion-protected: "true" --- apiVersion: helm.toolkit.fluxcd.io/v2 kind: HelmRelease @@ -10,6 +12,7 @@ metadata: annotations: helm.sh/resource-policy: keep labels: + cozystack.io/deletion-protected: "true" sharding.fluxcd.io/key: tenants apps.cozystack.io/application.kind: Tenant apps.cozystack.io/application.group: apps.cozystack.io diff --git a/packages/system/lineage-controller-webhook/templates/validatingwebhookconfiguration.yaml b/packages/system/lineage-controller-webhook/templates/validatingwebhookconfiguration.yaml new file mode 100644 index 00000000..24aa264e --- /dev/null +++ b/packages/system/lineage-controller-webhook/templates/validatingwebhookconfiguration.yaml @@ -0,0 +1,53 @@ +apiVersion: admissionregistration.k8s.io/v1 +kind: ValidatingWebhookConfiguration +metadata: + name: deletion-protection + annotations: + cert-manager.io/inject-ca-from: {{ .Release.Namespace }}/lineage-controller-webhook + labels: + app: lineage-controller-webhook +webhooks: + - name: deletion-protection.cozystack.io + admissionReviewVersions: ["v1"] + sideEffects: None + clientConfig: + service: + name: lineage-controller-webhook + namespace: {{ .Release.Namespace }} + path: /validate-deletion + rules: + - operations: ["DELETE"] + apiGroups: [""] + apiVersions: ["v1"] + resources: ["namespaces", "configmaps"] + scope: "*" + - operations: ["DELETE"] + apiGroups: ["helm.toolkit.fluxcd.io"] + apiVersions: ["v2"] + resources: ["helmreleases"] + scope: Namespaced + - operations: ["DELETE"] + apiGroups: ["apiextensions.k8s.io"] + apiVersions: ["v1"] + resources: ["customresourcedefinitions"] + scope: Cluster + - operations: ["DELETE"] + apiGroups: ["piraeus.io"] + apiVersions: ["v1"] + resources: ["linstorclusters"] + scope: Cluster + - operations: ["DELETE"] + apiGroups: ["cert-manager.io"] + apiVersions: ["v1"] + resources: ["clusterissuers"] + scope: Cluster + - operations: ["DELETE"] + apiGroups: ["source.toolkit.fluxcd.io"] + apiVersions: ["v1"] + resources: ["ocirepositories"] + scope: Namespaced + failurePolicy: Fail + timeoutSeconds: 5 + objectSelector: + matchLabels: + cozystack.io/deletion-protected: "true" diff --git a/packages/system/linstor/templates/cluster.yaml b/packages/system/linstor/templates/cluster.yaml index e45b1dff..1bfced6c 100644 --- a/packages/system/linstor/templates/cluster.yaml +++ b/packages/system/linstor/templates/cluster.yaml @@ -2,6 +2,8 @@ apiVersion: piraeus.io/v1 kind: LinstorCluster metadata: name: linstorcluster + labels: + cozystack.io/deletion-protected: "true" spec: #nodeSelector: # node-role.kubernetes.io/linstor: ""