Compare commits
11 commits
main
...
feature/de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2157452da3 | ||
|
|
4a4354a7e1 | ||
|
|
5153c1599b | ||
|
|
4576435558 | ||
|
|
14a909dba3 | ||
|
|
facfc90666 | ||
|
|
6654ca7a67 | ||
|
|
0ce56ae338 | ||
|
|
f36b7d64d5 | ||
|
|
b1b6cf40f7 | ||
|
|
1cbe69661a |
11 changed files with 365 additions and 12 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
106
internal/lineagecontrollerwebhook/validate_deletion.go
Normal file
106
internal/lineagecontrollerwebhook/validate_deletion.go
Normal 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
|
||||
}
|
||||
}
|
||||
164
internal/lineagecontrollerwebhook/validate_deletion_test.go
Normal file
164
internal/lineagecontrollerwebhook/validate_deletion_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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: {}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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: ""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue