Compare commits

...
Sign in to create a new pull request.

11 commits

Author SHA1 Message Date
Myasnikov Daniil
2157452da3
[webhook] Fix review issues: LinstorCluster scope, constant export, whitespace
- LinstorCluster is cluster-scoped: fix webhook rule scope to Cluster,
  remove namespace from kindToResourceArg and test
- OCIRepository webhook rule: scope "*" -> Namespaced
- Unexport DeletionProtectedLabel constant (only used within package)
- Normalize trailing whitespace in cert-manager-issuers template

Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
2026-04-15 08:36:25 +05:00
Myasnikov Daniil
4a4354a7e1
[operator] Add deletion protection label to CRDs during install
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
2026-04-15 00:10:46 +05:00
Myasnikov Daniil
5153c1599b
[cert-manager] Add deletion protection label to ClusterIssuers
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
2026-04-15 00:10:40 +05:00
Myasnikov Daniil
4576435558
[linstor] Add deletion protection label to LinstorCluster
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
2026-04-15 00:10:35 +05:00
Myasnikov Daniil
14a909dba3
[platform] Add deletion protection label to cozystack-version ConfigMap and cozystack-packages OCIRepository
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
2026-04-15 00:10:30 +05:00
Myasnikov Daniil
facfc90666
[installer] Add deletion protection label to cozy-system namespace
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
2026-04-15 00:10:23 +05:00
Myasnikov Daniil
6654ca7a67
[platform] Add deletion protection label to tenant-root namespace and HelmRelease
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
2026-04-15 00:10:18 +05:00
Myasnikov Daniil
0ce56ae338
[webhook] Add ValidatingWebhookConfiguration for deletion protection
Label-based objectSelector (cozystack.io/deletion-protected=true) ensures
zero overhead for non-protected resources. Reuses the existing
lineage-controller-webhook Service and TLS certificates.

Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
2026-04-15 00:10:12 +05:00
Myasnikov Daniil
f36b7d64d5
[webhook] Register deletion protection handler on /validate-deletion
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
2026-04-15 00:08:49 +05:00
Myasnikov Daniil
b1b6cf40f7
[webhook] Add unit tests for deletion protection handler
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
2026-04-15 00:08:03 +05:00
Myasnikov Daniil
1cbe69661a
[webhook] Add deletion protection validating handler
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
2026-04-15 00:06:31 +05:00
11 changed files with 365 additions and 12 deletions

View file

@ -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 {

View file

@ -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{

View file

@ -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
}
}

View file

@ -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)
}
})
}
}

View file

@ -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:

View file

@ -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:

View file

@ -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 }}

View file

@ -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: {}

View file

@ -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

View file

@ -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"

View file

@ -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: ""