Skip to content

Commit f3d32ce

Browse files
committed
protect against accidental deletes of installed clusters
The admission webhook for CluterDeployments has been modified to inspect DELETE requests as well. The webhook will reject any DELETE request for a ClusterDeployment that has the "hive.openshift.io/protected-delete" annotation set to a true value. In order to delete such a ClusterDeployment, a user is required to delete the annotation prior to making the DELETE request. The "deleteProtection" field has been added to the HiveConfig. When the field is set to "enabled", the clusterdeployment controller will add the "hive.openshift.io/protected-delete" annotation to the ClusterDeployment when transitioning the ClusterDeployment to installed. https://issues.redhat.com/browse/CO-277
1 parent dc0c552 commit f3d32ce

10 files changed

Lines changed: 191 additions & 10 deletions

File tree

config/crds/hive.openshift.io_hiveconfigs.yaml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,15 @@ spec:
7070
type: boolean
7171
type: object
7272
type: object
73+
deleteProtection:
74+
description: DeleteProtection can be set to "enabled" to turn on automatic
75+
delete protection for ClusterDeployments. When enabled, Hive will
76+
add the "hive.openshift.io/protected-delete" annotation to new ClusterDeployments.
77+
Once a ClusterDeployment has been installed, a user must remove the
78+
annotation from a ClusterDeployment prior to deleting it.
79+
enum:
80+
- enabled
81+
type: string
7382
deprovisionsDisabled:
7483
description: DeprovisionsDisabled can be set to true to block deprovision
7584
jobs from running.

config/hiveadmission/clusterdeployment-webhook.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ webhooks:
1515
- operations:
1616
- CREATE
1717
- UPDATE
18+
- DELETE
1819
apiGroups:
1920
- hive.openshift.io
2021
apiVersions:

pkg/apis/hive/v1/hiveconfig_types.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,14 @@ type HiveConfigSpec struct {
6666

6767
// DeprovisionsDisabled can be set to true to block deprovision jobs from running.
6868
DeprovisionsDisabled *bool `json:"deprovisionsDisabled,omitempty"`
69+
70+
// DeleteProtection can be set to "enabled" to turn on automatic delete protection for ClusterDeployments. When
71+
// enabled, Hive will add the "hive.openshift.io/protected-delete" annotation to new ClusterDeployments. Once a
72+
// ClusterDeployment has been installed, a user must remove the annotation from a ClusterDeployment prior to
73+
// deleting it.
74+
// +kubebuilder:validation:Enum=enabled
75+
// +optional
76+
DeleteProtection DeleteProtectionType `json:"deleteProtection,omitempty"`
6977
}
7078

7179
// HiveConfigStatus defines the observed state of Hive
@@ -161,6 +169,12 @@ type ManageDNSGCPConfig struct {
161169
CredentialsSecretRef corev1.LocalObjectReference `json:"credentialsSecretRef,omitempty"`
162170
}
163171

172+
type DeleteProtectionType string
173+
174+
const (
175+
DeleteProtectionEnabled DeleteProtectionType = "enabled"
176+
)
177+
164178
// +genclient:nonNamespaced
165179
// +genclient
166180
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object

pkg/apis/hive/v1/validating-webhooks/clusterdeployment_validating_admission_hook.go

Lines changed: 65 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"net/http"
66
"reflect"
77
"regexp"
8+
"strconv"
89
"strings"
910

1011
log "github.com/sirupsen/logrus"
@@ -21,6 +22,7 @@ import (
2122
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
2223

2324
hivev1 "github.com/openshift/hive/pkg/apis/hive/v1"
25+
"github.com/openshift/hive/pkg/constants"
2426
"github.com/openshift/hive/pkg/manageddns"
2527
)
2628

@@ -113,18 +115,18 @@ func (a *ClusterDeploymentValidatingAdmissionHook) Validate(admissionSpec *admis
113115

114116
contextLogger.Info("Validating request")
115117

116-
if admissionSpec.Operation == admissionv1beta1.Create {
118+
switch admissionSpec.Operation {
119+
case admissionv1beta1.Create:
117120
return a.validateCreate(admissionSpec)
118-
}
119-
120-
if admissionSpec.Operation == admissionv1beta1.Update {
121+
case admissionv1beta1.Update:
121122
return a.validateUpdate(admissionSpec)
122-
}
123-
124-
// We're only validating creates and updates at this time, so all other operations are explicitly allowed.
125-
contextLogger.Info("Successful validation")
126-
return &admissionv1beta1.AdmissionResponse{
127-
Allowed: true,
123+
case admissionv1beta1.Delete:
124+
return a.validateDelete(admissionSpec)
125+
default:
126+
contextLogger.Info("Successful validation")
127+
return &admissionv1beta1.AdmissionResponse{
128+
Allowed: true,
129+
}
128130
}
129131
}
130132

@@ -438,6 +440,59 @@ func (a *ClusterDeploymentValidatingAdmissionHook) validateUpdate(admissionSpec
438440
}
439441
}
440442

443+
// validateDelete specifically validates delete operations for ClusterDeployment objects.
444+
func (a *ClusterDeploymentValidatingAdmissionHook) validateDelete(request *admissionv1beta1.AdmissionRequest) *admissionv1beta1.AdmissionResponse {
445+
logger := log.WithFields(log.Fields{
446+
"operation": request.Operation,
447+
"group": request.Resource.Group,
448+
"version": request.Resource.Version,
449+
"resource": request.Resource.Resource,
450+
"method": "validateDelete",
451+
})
452+
453+
oldObject := &hivev1.ClusterDeployment{}
454+
if err := a.decoder.DecodeRaw(request.OldObject, oldObject); err != nil {
455+
logger.Errorf("Failed unmarshaling Object: %v", err.Error())
456+
return &admissionv1beta1.AdmissionResponse{
457+
Allowed: false,
458+
Result: &metav1.Status{
459+
Status: metav1.StatusFailure, Code: http.StatusBadRequest, Reason: metav1.StatusReasonBadRequest,
460+
Message: err.Error(),
461+
},
462+
}
463+
}
464+
465+
logger.Data["object.Name"] = oldObject.Name
466+
467+
var allErrs field.ErrorList
468+
469+
if value, present := oldObject.Annotations[constants.ProtectedDeleteAnnotation]; present {
470+
if enabled, err := strconv.ParseBool(value); enabled && err == nil {
471+
allErrs = append(allErrs, field.Invalid(
472+
field.NewPath("metadata", "annotations", constants.ProtectedDeleteAnnotation),
473+
oldObject.Annotations[constants.ProtectedDeleteAnnotation],
474+
"cannot delete while annotation is present",
475+
))
476+
} else {
477+
logger.WithField(constants.ProtectedDeleteAnnotation, value).Info("Protected Delete annotation present but not set to true")
478+
}
479+
}
480+
481+
if len(allErrs) > 0 {
482+
logger.WithError(allErrs.ToAggregate()).Info("failed validation")
483+
status := errors.NewInvalid(schemaGVK(request.Kind).GroupKind(), request.Name, allErrs).Status()
484+
return &admissionv1beta1.AdmissionResponse{
485+
Allowed: false,
486+
Result: &status,
487+
}
488+
}
489+
490+
logger.Info("Successful validation")
491+
return &admissionv1beta1.AdmissionResponse{
492+
Allowed: true,
493+
}
494+
}
495+
441496
// isFieldMutable says whether the ClusterDeployment.spec field is meant to be mutable or not.
442497
func isFieldMutable(value string) bool {
443498
for _, mutableField := range mutableFields {

pkg/apis/hive/v1/validating-webhooks/clusterdeployment_validating_admission_hook_test.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -658,6 +658,38 @@ func TestClusterDeploymentValidate(t *testing.T) {
658658
operation: admissionv1beta1.Create,
659659
expectedAllowed: true,
660660
},
661+
{
662+
name: "Test valid delete",
663+
oldObject: validAWSClusterDeployment(),
664+
operation: admissionv1beta1.Delete,
665+
expectedAllowed: true,
666+
},
667+
{
668+
name: "Test protected delete",
669+
oldObject: func() *hivev1.ClusterDeployment {
670+
cd := validAWSClusterDeployment()
671+
if cd.Annotations == nil {
672+
cd.Annotations = make(map[string]string, 1)
673+
}
674+
cd.Annotations[constants.ProtectedDeleteAnnotation] = "true"
675+
return cd
676+
}(),
677+
operation: admissionv1beta1.Delete,
678+
expectedAllowed: false,
679+
},
680+
{
681+
name: "Test protected delete annotation false",
682+
oldObject: func() *hivev1.ClusterDeployment {
683+
cd := validAWSClusterDeployment()
684+
if cd.Annotations == nil {
685+
cd.Annotations = make(map[string]string, 1)
686+
}
687+
cd.Annotations[constants.ProtectedDeleteAnnotation] = "false"
688+
return cd
689+
}(),
690+
operation: admissionv1beta1.Delete,
691+
expectedAllowed: true,
692+
},
661693
}
662694

663695
for _, tc := range cases {

pkg/constants/constants.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,14 @@ const (
128128
// for the cluster provision to complete by running `openshift-install wait-for install-complete` command.
129129
WaitForInstallCompleteExecutionsAnnotation = "hive.openshift.io/wait-for-install-complete-executions"
130130

131+
// ProtectedDeleteAnnotation is an annotation used on ClusterDeployments to indicate that the ClusterDeployment
132+
// cannot be deleted. The annotation must be removed in order to delete the ClusterDeployment.
133+
ProtectedDeleteAnnotation = "hive.openshift.io/protected-delete"
134+
135+
// ProtectedDeleteEnvVar is the name of the environment variable used to tell the controller manager whether
136+
// protected delete is enabled.
137+
ProtectedDeleteEnvVar = "PROTECTED_DELETE"
138+
131139
// ManagedDomainsFileEnvVar if present, points to a simple text
132140
// file that includes a valid managed domain per line. Cluster deployments
133141
// requesting that their domains be managed must have a base domain

pkg/controller/clusterdeployment/clusterdeployment_controller.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"os"
77
"reflect"
88
"sort"
9+
"strconv"
910
"strings"
1011
"time"
1112

@@ -155,6 +156,13 @@ func NewReconciler(mgr manager.Manager) reconcile.Reconciler {
155156
r.remoteClusterAPIClientBuilder = func(cd *hivev1.ClusterDeployment) remoteclient.Builder {
156157
return remoteclient.NewBuilder(r.Client, cd, controllerName)
157158
}
159+
160+
protectedDeleteEnvVar := os.Getenv(constants.ProtectedDeleteEnvVar)
161+
if protectedDelete, err := strconv.ParseBool(protectedDeleteEnvVar); protectedDelete && err == nil {
162+
logger.Info("Protected Delete enabled")
163+
r.protectedDelete = true
164+
}
165+
158166
return r
159167
}
160168

@@ -248,6 +256,8 @@ type ReconcileClusterDeployment struct {
248256
// remoteClusterAPIClientBuilder is a function pointer to the function that gets a builder for building a client
249257
// for the remote cluster's API server
250258
remoteClusterAPIClientBuilder func(cd *hivev1.ClusterDeployment) remoteclient.Builder
259+
260+
protectedDelete bool
251261
}
252262

253263
// Reconcile reads that state of the cluster for a ClusterDeployment object and makes changes based on the state read
@@ -808,6 +818,13 @@ func (r *ReconcileClusterDeployment) reconcileCompletedProvision(cd *hivev1.Clus
808818

809819
cd.Spec.Installed = true
810820

821+
if r.protectedDelete {
822+
if _, annotationPresent := cd.Annotations[constants.ProtectedDeleteAnnotation]; !annotationPresent {
823+
initializeAnnotations(cd)
824+
cd.Annotations[constants.ProtectedDeleteAnnotation] = "true"
825+
}
826+
}
827+
811828
if err := r.Update(context.TODO(), cd); err != nil {
812829
cdLog.WithError(err).Log(controllerutils.LogLevel(err), "failed to set the Installed flag")
813830
return reconcile.Result{}, err

pkg/controller/clusterdeployment/clusterdeployment_controller_test.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ func TestClusterDeploymentReconcile(t *testing.T) {
122122
expectPendingCreation bool
123123
expectConsoleRouteFetch bool
124124
validate func(client.Client, *testing.T)
125+
reconcilerSetup func(*ReconcileClusterDeployment)
125126
}{
126127
{
127128
name: "Add finalizer",
@@ -284,6 +285,28 @@ func TestClusterDeploymentReconcile(t *testing.T) {
284285
cd := getCD(c)
285286
if assert.NotNil(t, cd, "missing clusterdeployment") {
286287
assert.True(t, cd.Spec.Installed, "expected cluster to be installed")
288+
assert.NotContains(t, cd.Annotations, constants.ProtectedDeleteAnnotation, "unexpected protected delete annotation")
289+
}
290+
},
291+
},
292+
{
293+
name: "Completed provision with protected delete",
294+
existing: []runtime.Object{
295+
testClusterDeploymentWithProvision(),
296+
testSuccessfulProvision(),
297+
testMetadataConfigMap(),
298+
testSecret(corev1.SecretTypeOpaque, adminKubeconfigSecret, "kubeconfig", adminKubeconfig),
299+
testSecret(corev1.SecretTypeDockerConfigJson, pullSecretSecret, corev1.DockerConfigJsonKey, "{}"),
300+
testSecret(corev1.SecretTypeDockerConfigJson, constants.GetMergedPullSecretName(testClusterDeployment()), corev1.DockerConfigJsonKey, "{}"),
301+
},
302+
reconcilerSetup: func(r *ReconcileClusterDeployment) {
303+
r.protectedDelete = true
304+
},
305+
validate: func(c client.Client, t *testing.T) {
306+
cd := getCD(c)
307+
if assert.NotNil(t, cd, "missing clusterdeployment") {
308+
assert.True(t, cd.Spec.Installed, "expected cluster to be installed")
309+
assert.Equal(t, "true", cd.Annotations[constants.ProtectedDeleteAnnotation], "unexpected protected delete annotation")
287310
}
288311
},
289312
},
@@ -1129,6 +1152,10 @@ func TestClusterDeploymentReconcile(t *testing.T) {
11291152
remoteClusterAPIClientBuilder: func(*hivev1.ClusterDeployment) remoteclient.Builder { return mockRemoteClientBuilder },
11301153
}
11311154

1155+
if test.reconcilerSetup != nil {
1156+
test.reconcilerSetup(rcd)
1157+
}
1158+
11321159
reconcileRequest := reconcile.Request{
11331160
NamespacedName: types.NamespacedName{
11341161
Name: testName,

pkg/operator/assets/bindata.go

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/operator/hive/hive.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,14 @@ func (r *ReconcileHiveConfig) deployHive(hLog log.FieldLogger, h *resource.Helpe
133133
hiveContainer.Env = append(hiveContainer.Env, tmpEnvVar)
134134
}
135135

136+
if instance.Spec.DeleteProtection == hivev1.DeleteProtectionEnabled {
137+
hLog.Info("Delete Protection enabled")
138+
hiveContainer.Env = append(hiveContainer.Env, corev1.EnvVar{
139+
Name: hiveconstants.ProtectedDeleteEnvVar,
140+
Value: "true",
141+
})
142+
}
143+
136144
if err := r.includeAdditionalCAs(hLog, h, instance, hiveDeployment); err != nil {
137145
return err
138146
}

0 commit comments

Comments
 (0)