This is an automated email from the ASF dual-hosted git repository.

HoustonPutman pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/solr-operator.git


The following commit(s) were added to refs/heads/main by this push:
     new be27e00  Emit Kubernetes events from the operator controllers (#836)
be27e00 is described below

commit be27e001dfd2851290eb9ca4a12b2f7ccb004924
Author: Houston Putman <[email protected]>
AuthorDate: Mon Jun 15 11:27:22 2026 -0700

    Emit Kubernetes events from the operator controllers (#836)
    
    Fills in the // TODO: Create event markers and adds event recording across 
the SolrCloud, SolrPrometheusExporter, and SolrBackup controllers (config 
validation, scheduled restarts, cluster operations, replica migration, PVC 
expansion, scaling, and backup lifecycle).
    
    Also grants the operator RBAC to create events — without it the broadcaster 
silently dropped every event, so the previously merged PVC-expansion events 
never persisted either.
    
    Adds a no-op EventRecorder so reconcilers call the recorder 
unconditionally, and wires recorders for the exporter and backup controllers.
    
    Tested at three layers: unit (FakeRecorder), envtest reconcile, and e2e 
(backups, scaling, rolling upgrades).
    
    Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
    Co-authored-by: Copilot Autofix powered by AI 
<[email protected]>
---
 config/rbac/role.yaml                            |   7 +
 controllers/event_recorder.go                    |  37 +++
 controllers/events_test.go                       | 298 +++++++++++++++++++++++
 controllers/reconcile_events_test.go             | 241 ++++++++++++++++++
 controllers/solr_cluster_ops_util.go             |  51 ++--
 controllers/solr_pod_lifecycle_util.go           |  16 +-
 controllers/solrbackup_controller.go             |  31 ++-
 controllers/solrcloud_controller.go              |  40 ++-
 controllers/solrprometheusexporter_controller.go |  14 +-
 controllers/suite_test.go                        |  12 +-
 controllers/util/solr_update_util.go             |   9 +-
 helm/solr-operator/Chart.yaml                    |   7 +
 helm/solr-operator/templates/role.yaml           |   7 +
 main.go                                          |  14 +-
 tests/e2e/backups_test.go                        |   8 +
 tests/e2e/resource_utils_test.go                 |  20 ++
 tests/e2e/solrcloud_rolling_upgrade_test.go      |   5 +
 tests/e2e/solrcloud_scaling_test.go              |  13 +
 18 files changed, 772 insertions(+), 58 deletions(-)

diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml
index 53b8d47..a6b37db 100644
--- a/config/rbac/role.yaml
+++ b/config/rbac/role.yaml
@@ -39,6 +39,13 @@ rules:
   - services/status
   verbs:
   - get
+- apiGroups:
+  - ""
+  resources:
+  - events
+  verbs:
+  - create
+  - patch
 - apiGroups:
   - ""
   resources:
diff --git a/controllers/event_recorder.go b/controllers/event_recorder.go
new file mode 100644
index 0000000..b5cf321
--- /dev/null
+++ b/controllers/event_recorder.go
@@ -0,0 +1,37 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 controllers
+
+import (
+       "k8s.io/apimachinery/pkg/runtime"
+       "k8s.io/client-go/tools/record"
+)
+
+// noOpEventRecorder is a record.EventRecorder that discards all events.
+// It is used as a default so that a reconciler's Recorder is never nil,
+// allowing event call-sites to invoke it unconditionally.
+type noOpEventRecorder struct{}
+
+var _ record.EventRecorder = noOpEventRecorder{}
+
+func (noOpEventRecorder) Event(_ runtime.Object, _, _, _ string) {}
+
+func (noOpEventRecorder) Eventf(_ runtime.Object, _, _, _ string, _ 
...interface{}) {}
+
+func (noOpEventRecorder) AnnotatedEventf(_ runtime.Object, _ 
map[string]string, _, _, _ string, _ ...interface{}) {
+}
diff --git a/controllers/events_test.go b/controllers/events_test.go
new file mode 100644
index 0000000..f2cdb56
--- /dev/null
+++ b/controllers/events_test.go
@@ -0,0 +1,298 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 controllers
+
+import (
+       "context"
+       "errors"
+       "strings"
+       "testing"
+
+       solrv1beta1 "github.com/apache/solr-operator/api/v1beta1"
+       "github.com/apache/solr-operator/controllers/util"
+       "github.com/go-logr/logr"
+       appsv1 "k8s.io/api/apps/v1"
+       corev1 "k8s.io/api/core/v1"
+       "k8s.io/apimachinery/pkg/api/resource"
+       metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+       "k8s.io/apimachinery/pkg/runtime"
+       clientgoscheme "k8s.io/client-go/kubernetes/scheme"
+       "k8s.io/client-go/tools/record"
+       "sigs.k8s.io/controller-runtime/pkg/client"
+       "sigs.k8s.io/controller-runtime/pkg/client/fake"
+       "sigs.k8s.io/controller-runtime/pkg/client/interceptor"
+)
+
+// errForcedPatchFailure is returned by the fake client to force the status 
patch to fail,
+// so that the "could not patch readiness condition" event path is exercised.
+var errForcedPatchFailure = errors.New("forced patch failure")
+
+// requireEvent asserts that one event was recorded whose type and reason 
match.
+// record.FakeRecorder formats each event as "<eventtype> <reason> <message>".
+func requireEvent(t *testing.T, rec *record.FakeRecorder, wantType, wantReason 
string) {
+       t.Helper()
+       select {
+       case got := <-rec.Events:
+               if !strings.HasPrefix(got, wantType+" "+wantReason+" ") {
+                       t.Errorf("expected event of type %q with reason %q, got 
%q", wantType, wantReason, got)
+               }
+       default:
+               t.Fatalf("expected an event with reason %q to be recorded, but 
none was", wantReason)
+       }
+}
+
+// requireNoEvent asserts that no event is currently buffered on the recorder.
+func requireNoEvent(t *testing.T, rec *record.FakeRecorder) {
+       t.Helper()
+       select {
+       case got := <-rec.Events:
+               t.Fatalf("expected no event to be recorded, but got %q", got)
+       default:
+       }
+}
+
+// solrCloudWithStorageRequest builds a minimal SolrCloud requesting the given 
persistent data size.
+func solrCloudWithStorageRequest(size string) *solrv1beta1.SolrCloud {
+       return &solrv1beta1.SolrCloud{
+               ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "test"},
+               Spec: solrv1beta1.SolrCloudSpec{
+                       StorageOptions: solrv1beta1.SolrDataStorageOptions{
+                               PersistentStorage: 
&solrv1beta1.SolrPersistentDataStorageOptions{
+                                       PersistentVolumeClaimTemplate: 
solrv1beta1.PersistentVolumeClaimTemplate{
+                                               Spec: 
corev1.PersistentVolumeClaimSpec{
+                                                       Resources: 
corev1.VolumeResourceRequirements{
+                                                               Requests: 
corev1.ResourceList{
+                                                                       
corev1.ResourceStorage: resource.MustParse(size),
+                                                               },
+                                                       },
+                                               },
+                                       },
+                               },
+                       },
+               },
+       }
+}
+
+// podWithReadinessGate builds a Pod that advertises the given readiness gate 
(and no status yet).
+func podWithReadinessGate(condType corev1.PodConditionType) *corev1.Pod {
+       return &corev1.Pod{
+               ObjectMeta: metav1.ObjectMeta{Name: "test-pod", Namespace: 
"test"},
+               Spec: corev1.PodSpec{
+                       ReadinessGates: 
[]corev1.PodReadinessGate{{ConditionType: condType}},
+               },
+       }
+}
+
+// failingStatusPatchClient returns a fake client whose Status().Patch always 
fails, so that
+// readiness-condition patch failures (and their events) can be exercised 
deterministically.
+func failingStatusPatchClient() client.Client {
+       return fake.NewClientBuilder().
+               WithScheme(clientgoscheme.Scheme).
+               WithInterceptorFuncs(interceptor.Funcs{
+                       SubResourcePatch: func(_ context.Context, _ 
client.Client, _ string, _ client.Object, _ client.Patch, _ 
...client.SubResourcePatchOption) error {
+                               return errForcedPatchFailure
+                       },
+               }).
+               Build()
+}
+
+// TestDeterminePvcExpansionEmitsErrorEventOnBadAnnotation verifies a 
PVCExpansionError warning is
+// emitted when the existing minimum-size annotation recorded on the 
StatefulSet cannot be parsed.
+func TestDeterminePvcExpansionEmitsErrorEventOnBadAnnotation(t *testing.T) {
+       rec := record.NewFakeRecorder(8)
+       r := &SolrCloudReconciler{Recorder: rec}
+       instance := solrCloudWithStorageRequest("5Gi")
+       sts := &appsv1.StatefulSet{}
+       sts.Annotations = map[string]string{util.StorageMinimumSizeAnnotation: 
"not-a-quantity"}
+
+       clusterOp, _, err := 
determinePvcExpansionClusterOpLockIfNecessary(context.Background(), r, 
instance, sts, logr.Discard())
+       if err == nil {
+               t.Error("expected an error parsing the existing PVC size 
annotation, got nil")
+       }
+       if clusterOp != nil {
+               t.Errorf("expected no cluster operation to be started, got 
%+v", clusterOp)
+       }
+       requireEvent(t, rec, corev1.EventTypeWarning, "PVCExpansionError")
+}
+
+// TestDeterminePvcExpansionEmitsForbiddenEventOnShrink verifies a 
PVCExpansionForbidden warning is
+// emitted (and no cluster op started) when the requested size is smaller than 
the existing size.
+func TestDeterminePvcExpansionEmitsForbiddenEventOnShrink(t *testing.T) {
+       rec := record.NewFakeRecorder(8)
+       r := &SolrCloudReconciler{Recorder: rec}
+       instance := solrCloudWithStorageRequest("5Gi")
+       sts := &appsv1.StatefulSet{}
+       sts.Annotations = map[string]string{util.StorageMinimumSizeAnnotation: 
"10Gi"}
+
+       clusterOp, _, err := 
determinePvcExpansionClusterOpLockIfNecessary(context.Background(), r, 
instance, sts, logr.Discard())
+       if err != nil {
+               t.Errorf("did not expect an error for a shrink request, got 
%v", err)
+       }
+       if clusterOp != nil {
+               t.Errorf("expected no cluster operation for a shrink request, 
got %+v", clusterOp)
+       }
+       requireEvent(t, rec, corev1.EventTypeWarning, "PVCExpansionForbidden")
+}
+
+// TestDeterminePvcExpansionNoEventWhenSizeUnchanged verifies that the 
steady-state path (requested
+// size matches the recorded size) neither starts a cluster op nor records an 
event.
+func TestDeterminePvcExpansionNoEventWhenSizeUnchanged(t *testing.T) {
+       rec := record.NewFakeRecorder(8)
+       r := &SolrCloudReconciler{Recorder: rec}
+       instance := solrCloudWithStorageRequest("5Gi")
+       sts := &appsv1.StatefulSet{}
+       sts.Annotations = map[string]string{util.StorageMinimumSizeAnnotation: 
"5Gi"}
+
+       clusterOp, _, err := 
determinePvcExpansionClusterOpLockIfNecessary(context.Background(), r, 
instance, sts, logr.Discard())
+       if err != nil {
+               t.Errorf("did not expect an error, got %v", err)
+       }
+       if clusterOp != nil {
+               t.Errorf("expected no cluster operation, got %+v", clusterOp)
+       }
+       requireNoEvent(t, rec)
+}
+
+// TestHandleManagedScaleDownEmitsEventOnBadMetadata verifies a 
ClusterOperationError warning is
+// emitted when the scale-down target stored in the cluster-operation metadata 
cannot be parsed.
+func TestHandleManagedScaleDownEmitsEventOnBadMetadata(t *testing.T) {
+       rec := record.NewFakeRecorder(8)
+       r := &SolrCloudReconciler{Recorder: rec}
+       instance := &solrv1beta1.SolrCloud{ObjectMeta: metav1.ObjectMeta{Name: 
"test", Namespace: "test"}}
+       clusterOp := &SolrClusterOp{Operation: ScaleDownLock, Metadata: 
"not-an-int"}
+
+       _, _, _, err := handleManagedCloudScaleDown(context.Background(), r, 
instance, &appsv1.StatefulSet{}, clusterOp, nil, logr.Discard())
+       if err == nil {
+               t.Error("expected an error parsing the scale-down metadata, got 
nil")
+       }
+       requireEvent(t, rec, corev1.EventTypeWarning, "ClusterOperationError")
+}
+
+// TestInitializePodEmitsEventOnPatchFailure verifies that a failed 
readiness-condition patch while
+// starting traffic on a pod surfaces a PodReadinessConditionUpdateFailed 
warning.
+func TestInitializePodEmitsEventOnPatchFailure(t *testing.T) {
+       rec := record.NewFakeRecorder(8)
+       r := &SolrCloudReconciler{Client: failingStatusPatchClient(), Recorder: 
rec}
+       instance := &solrv1beta1.SolrCloud{ObjectMeta: metav1.ObjectMeta{Name: 
"test", Namespace: "test"}}
+       pod := podWithReadinessGate(util.SolrIsNotStoppedReadinessCondition)
+
+       if _, err := r.initializePod(context.Background(), instance, pod, 
logr.Discard()); err == nil {
+               t.Error("expected the forced patch failure to be returned, got 
nil")
+       }
+       requireEvent(t, rec, corev1.EventTypeWarning, 
"PodReadinessConditionUpdateFailed")
+}
+
+// TestEnsurePodReadinessConditionsEmitsEventOnPatchFailure verifies that a 
failed readiness-condition
+// patch while stopping traffic on a pod surfaces a 
PodReadinessConditionUpdateFailed warning.
+func TestEnsurePodReadinessConditionsEmitsEventOnPatchFailure(t *testing.T) {
+       rec := record.NewFakeRecorder(8)
+       r := &SolrCloudReconciler{Client: failingStatusPatchClient(), Recorder: 
rec}
+       instance := &solrv1beta1.SolrCloud{ObjectMeta: metav1.ObjectMeta{Name: 
"test", Namespace: "test"}}
+
+       pod := podWithReadinessGate(util.SolrIsNotStoppedReadinessCondition)
+       // Seed an existing condition with a different reason so a change (and 
thus a patch) is required.
+       pod.Status.Conditions = []corev1.PodCondition{{
+               Type:   util.SolrIsNotStoppedReadinessCondition,
+               Status: corev1.ConditionTrue,
+               Reason: string(PodStarted),
+       }}
+       ensureConditions := 
map[corev1.PodConditionType]podReadinessConditionChange{
+               util.SolrIsNotStoppedReadinessCondition: {
+                       reason:  PodUpdate,
+                       message: "Pod is being deleted, traffic to the pod must 
be stopped",
+                       status:  false,
+               },
+       }
+
+       if _, err := EnsurePodReadinessConditions(context.Background(), r, 
instance, pod, ensureConditions, logr.Discard()); err == nil {
+               t.Error("expected the forced patch failure to be returned, got 
nil")
+       }
+       requireEvent(t, rec, corev1.EventTypeWarning, 
"PodReadinessConditionUpdateFailed")
+}
+
+// TestHandleManagedScaleUpEmitsEventOnBadMetadata verifies a 
ClusterOperationError warning is
+// emitted when the scale-up target stored in the cluster-operation metadata 
cannot be parsed.
+func TestHandleManagedScaleUpEmitsEventOnBadMetadata(t *testing.T) {
+       rec := record.NewFakeRecorder(8)
+       r := &SolrCloudReconciler{Recorder: rec}
+       instance := &solrv1beta1.SolrCloud{ObjectMeta: metav1.ObjectMeta{Name: 
"test", Namespace: "test"}}
+       clusterOp := &SolrClusterOp{Operation: ScaleUpLock, Metadata: 
"not-an-int"}
+
+       if _, _, err := handleManagedCloudScaleUp(context.Background(), r, 
instance, &appsv1.StatefulSet{}, clusterOp, nil, logr.Discard()); err == nil {
+               t.Error("expected an error parsing the scale-up metadata, got 
nil")
+       }
+       requireEvent(t, rec, corev1.EventTypeWarning, "ClusterOperationError")
+}
+
+// TestHandlePvcExpansionEmitsEventOnBadMetadata verifies a PVCExpansionError 
warning is emitted
+// when the target PVC size stored in the cluster-operation metadata cannot be 
parsed.
+func TestHandlePvcExpansionEmitsEventOnBadMetadata(t *testing.T) {
+       rec := record.NewFakeRecorder(8)
+       r := &SolrCloudReconciler{Recorder: rec}
+       instance := &solrv1beta1.SolrCloud{ObjectMeta: metav1.ObjectMeta{Name: 
"test", Namespace: "test"}}
+       clusterOp := &SolrClusterOp{Operation: PvcExpansionLock, Metadata: 
"not-a-quantity"}
+
+       if _, _, err := handlePvcExpansion(context.Background(), r, instance, 
&appsv1.StatefulSet{}, clusterOp, logr.Discard()); err == nil {
+               t.Error("expected an error parsing the PVC expansion metadata, 
got nil")
+       }
+       requireEvent(t, rec, corev1.EventTypeWarning, "PVCExpansionError")
+}
+
+// TestReconcileSolrCloudBackupEmitsCloudNotReadyEvent verifies a 
BackupCloudNotReady warning is
+// emitted when a backup is attempted against a repository that the SolrCloud 
has not yet marked
+// available. A GCS repository is used so that EnsureDirectoryForBackup is a 
no-op (no pod exec).
+func TestReconcileSolrCloudBackupEmitsCloudNotReadyEvent(t *testing.T) {
+       scheme := runtime.NewScheme()
+       if err := clientgoscheme.AddToScheme(scheme); err != nil {
+               t.Fatalf("could not add client-go types to scheme: %v", err)
+       }
+       if err := solrv1beta1.AddToScheme(scheme); err != nil {
+               t.Fatalf("could not add solr types to scheme: %v", err)
+       }
+
+       solrCloud := &solrv1beta1.SolrCloud{
+               ObjectMeta: metav1.ObjectMeta{Name: "cloud", Namespace: "test"},
+               Spec: solrv1beta1.SolrCloudSpec{
+                       BackupRepositories: []solrv1beta1.SolrBackupRepository{{
+                               Name: "test-repo",
+                               GCS:  &solrv1beta1.GcsRepository{Bucket: 
"test-bucket"},
+                       }},
+               },
+               Status: solrv1beta1.SolrCloudStatus{
+                       BackupRepositoriesAvailable: 
map[string]bool{"test-repo": false},
+               },
+       }
+       backup := &solrv1beta1.SolrBackup{
+               ObjectMeta: metav1.ObjectMeta{Name: "backup", Namespace: 
"test"},
+               Spec: solrv1beta1.SolrBackupSpec{
+                       SolrCloud:      "cloud",
+                       RepositoryName: "test-repo",
+               },
+       }
+
+       rec := record.NewFakeRecorder(8)
+       r := &SolrBackupReconciler{
+               Client:   
fake.NewClientBuilder().WithScheme(scheme).WithObjects(solrCloud).Build(),
+               Recorder: rec,
+       }
+
+       if _, _, err := r.reconcileSolrCloudBackup(context.Background(), 
backup, &backup.Status.IndividualSolrBackupStatus, logr.Discard()); err == nil {
+               t.Error("expected a 'cloud not ready' error, got nil")
+       }
+       requireEvent(t, rec, corev1.EventTypeWarning, "BackupCloudNotReady")
+}
diff --git a/controllers/reconcile_events_test.go 
b/controllers/reconcile_events_test.go
new file mode 100644
index 0000000..8655e8e
--- /dev/null
+++ b/controllers/reconcile_events_test.go
@@ -0,0 +1,241 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 controllers
+
+import (
+       "context"
+       "fmt"
+
+       solrv1beta1 "github.com/apache/solr-operator/api/v1beta1"
+       . "github.com/onsi/ginkgo/v2"
+       . "github.com/onsi/gomega"
+       corev1 "k8s.io/api/core/v1"
+       metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+       "sigs.k8s.io/controller-runtime/pkg/client"
+)
+
+// expectEvent waits until at least one Event of the given type and reason has 
been recorded against
+// the given object. Events are matched on the involved object's UID so that 
events left over from
+// other specs (cleanupTest does not delete Events) cannot cause a false 
positive.
+func expectEvent(ctx context.Context, involvedObject client.Object, eventType, 
reason string, additionalOffset ...int) {
+       EventuallyWithOffset(resolveOffset(additionalOffset), func(g Gomega) {
+               eventList := &corev1.EventList{}
+               g.Expect(k8sClient.List(ctx, eventList, 
client.InNamespace(involvedObject.GetNamespace()))).To(Succeed())
+               found := false
+               for i := range eventList.Items {
+                       e := &eventList.Items[i]
+                       if e.InvolvedObject.UID == involvedObject.GetUID() && 
e.Type == eventType && e.Reason == reason {
+                               found = true
+                               break
+                       }
+               }
+               g.Expect(found).To(BeTrue(), fmt.Sprintf("expected a %q event 
with reason %q on %s/%s", eventType, reason, involvedObject.GetNamespace(), 
involvedObject.GetName()))
+       }).Should(Succeed())
+}
+
+var _ = FDescribe("SolrCloud controller - Events", func() {
+       var (
+               solrCloud *solrv1beta1.SolrCloud
+       )
+
+       BeforeEach(func() {
+               solrCloud = &solrv1beta1.SolrCloud{
+                       ObjectMeta: metav1.ObjectMeta{
+                               Name:      "events",
+                               Namespace: "default",
+                       },
+                       Spec: solrv1beta1.SolrCloudSpec{
+                               ZookeeperRef: &solrv1beta1.ZookeeperRef{
+                                       ConnectionInfo: 
&solrv1beta1.ZookeeperConnectionInfo{
+                                               InternalConnectionString: 
"host:7271",
+                                       },
+                               },
+                       },
+               }
+       })
+
+       AfterEach(func(ctx context.Context) {
+               cleanupTest(ctx, solrCloud)
+       })
+
+       FContext("with a scheduled restart configured", func() {
+               BeforeEach(func() {
+                       solrCloud.Spec.UpdateStrategy = 
solrv1beta1.SolrUpdateStrategy{
+                               RestartSchedule: "@every 10h",
+                       }
+               })
+
+               FIt("records a RestartScheduled event on the SolrCloud", 
func(ctx context.Context) {
+                       By("creating the SolrCloud")
+                       Expect(k8sClient.Create(ctx, solrCloud)).To(Succeed())
+
+                       By("waiting for the SolrCloud to be fully defaulted")
+                       expectSolrCloudWithChecks(ctx, solrCloud, func(g 
Gomega, found *solrv1beta1.SolrCloud) {
+                               
g.Expect(found.WithDefaults(logger)).To(BeFalse(), "The SolrCloud spec should 
not need to be defaulted eventually")
+                       })
+
+                       By("checking that a RestartScheduled event was 
recorded")
+                       expectEvent(ctx, solrCloud, corev1.EventTypeNormal, 
"RestartScheduled")
+               })
+       })
+
+       FContext("with a provided ConfigMap that is missing the required keys", 
func() {
+               var providedConfigMap *corev1.ConfigMap
+
+               BeforeEach(func() {
+                       providedConfigMap = &corev1.ConfigMap{
+                               ObjectMeta: metav1.ObjectMeta{
+                                       Name:      "invalid-provided-config",
+                                       Namespace: solrCloud.Namespace,
+                               },
+                               // Neither "solr.xml" nor "log4j2.xml" is 
present, which is invalid.
+                               Data: map[string]string{"unrelated-key": 
"unrelated-value"},
+                       }
+                       solrCloud.Spec.CustomSolrKubeOptions = 
solrv1beta1.CustomSolrKubeOptions{
+                               ConfigMapOptions: &solrv1beta1.ConfigMapOptions{
+                                       ProvidedConfigMap: 
providedConfigMap.Name,
+                               },
+                       }
+               })
+
+               FIt("records an InvalidConfigMap event on the SolrCloud", 
func(ctx context.Context) {
+                       By("creating the invalid ConfigMap")
+                       Expect(k8sClient.Create(ctx, 
providedConfigMap)).To(Succeed())
+
+                       By("creating the SolrCloud")
+                       Expect(k8sClient.Create(ctx, solrCloud)).To(Succeed())
+
+                       By("waiting for the SolrCloud to be fully defaulted")
+                       expectSolrCloudWithChecks(ctx, solrCloud, func(g 
Gomega, found *solrv1beta1.SolrCloud) {
+                               
g.Expect(found.WithDefaults(logger)).To(BeFalse(), "The SolrCloud spec should 
not need to be defaulted eventually")
+                       })
+
+                       By("checking that an InvalidConfigMap event was 
recorded")
+                       expectEvent(ctx, solrCloud, corev1.EventTypeWarning, 
"InvalidConfigMap")
+               })
+       })
+
+       FContext("with a provided ConfigMap whose solr.xml is missing the port 
placeholder", func() {
+               var providedConfigMap *corev1.ConfigMap
+
+               BeforeEach(func() {
+                       providedConfigMap = &corev1.ConfigMap{
+                               ObjectMeta: metav1.ObjectMeta{
+                                       Name:      "invalid-solrxml-config",
+                                       Namespace: solrCloud.Namespace,
+                               },
+                               // solr.xml is present but does not contain the 
required port placeholder.
+                               Data: map[string]string{"solr.xml": 
"<solr></solr>"},
+                       }
+                       solrCloud.Spec.CustomSolrKubeOptions = 
solrv1beta1.CustomSolrKubeOptions{
+                               ConfigMapOptions: &solrv1beta1.ConfigMapOptions{
+                                       ProvidedConfigMap: 
providedConfigMap.Name,
+                               },
+                       }
+               })
+
+               FIt("records an InvalidConfigMap event on the SolrCloud", 
func(ctx context.Context) {
+                       By("creating the invalid ConfigMap")
+                       Expect(k8sClient.Create(ctx, 
providedConfigMap)).To(Succeed())
+
+                       By("creating the SolrCloud")
+                       Expect(k8sClient.Create(ctx, solrCloud)).To(Succeed())
+
+                       By("waiting for the SolrCloud to be fully defaulted")
+                       expectSolrCloudWithChecks(ctx, solrCloud, func(g 
Gomega, found *solrv1beta1.SolrCloud) {
+                               
g.Expect(found.WithDefaults(logger)).To(BeFalse(), "The SolrCloud spec should 
not need to be defaulted eventually")
+                       })
+
+                       By("checking that an InvalidConfigMap event was 
recorded")
+                       expectEvent(ctx, solrCloud, corev1.EventTypeWarning, 
"InvalidConfigMap")
+               })
+       })
+})
+
+var _ = FDescribe("SolrPrometheusExporter controller - Events", func() {
+       var (
+               solrPrometheusExporter *solrv1beta1.SolrPrometheusExporter
+       )
+
+       BeforeEach(func() {
+               solrPrometheusExporter = &solrv1beta1.SolrPrometheusExporter{
+                       ObjectMeta: metav1.ObjectMeta{
+                               Name:      "events",
+                               Namespace: "default",
+                       },
+                       Spec: solrv1beta1.SolrPrometheusExporterSpec{
+                               SolrReference: solrv1beta1.SolrReference{
+                                       Cloud: &solrv1beta1.SolrCloudReference{
+                                               ZookeeperConnectionInfo: 
&solrv1beta1.ZookeeperConnectionInfo{
+                                                       
InternalConnectionString: "host:2181",
+                                               },
+                                       },
+                               },
+                               RestartSchedule: "@every 10h",
+                       },
+               }
+       })
+
+       AfterEach(func(ctx context.Context) {
+               cleanupTest(ctx, solrPrometheusExporter)
+       })
+
+       FIt("records a RestartScheduled event on the SolrPrometheusExporter", 
func(ctx context.Context) {
+               By("creating the SolrPrometheusExporter")
+               Expect(k8sClient.Create(ctx, 
solrPrometheusExporter)).To(Succeed())
+
+               By("waiting for the SolrPrometheusExporter to be fully 
defaulted")
+               expectSolrPrometheusExporterWithChecks(ctx, 
solrPrometheusExporter, func(g Gomega, found 
*solrv1beta1.SolrPrometheusExporter) {
+                       g.Expect(found.WithDefaults()).To(BeFalse(), "The 
SolrPrometheusExporter spec should not need to be defaulted eventually")
+               })
+
+               By("checking that a RestartScheduled event was recorded")
+               expectEvent(ctx, solrPrometheusExporter, 
corev1.EventTypeNormal, "RestartScheduled")
+       })
+
+       FContext("with a provided ConfigMap missing the required key", func() {
+               var providedConfigMap *corev1.ConfigMap
+
+               BeforeEach(func() {
+                       providedConfigMap = &corev1.ConfigMap{
+                               ObjectMeta: metav1.ObjectMeta{
+                                       Name:      "invalid-exporter-config",
+                                       Namespace: 
solrPrometheusExporter.Namespace,
+                               },
+                               // The required exporter config key is absent, 
which is invalid.
+                               Data: map[string]string{"unrelated-key": 
"unrelated-value"},
+                       }
+                       solrPrometheusExporter.Spec.CustomKubeOptions = 
solrv1beta1.CustomExporterKubeOptions{
+                               ConfigMapOptions: &solrv1beta1.ConfigMapOptions{
+                                       ProvidedConfigMap: 
providedConfigMap.Name,
+                               },
+                       }
+               })
+
+               FIt("records an InvalidConfigMap event on the 
SolrPrometheusExporter", func(ctx context.Context) {
+                       By("creating the invalid ConfigMap")
+                       Expect(k8sClient.Create(ctx, 
providedConfigMap)).To(Succeed())
+
+                       By("creating the SolrPrometheusExporter")
+                       Expect(k8sClient.Create(ctx, 
solrPrometheusExporter)).To(Succeed())
+
+                       By("checking that an InvalidConfigMap event was 
recorded")
+                       expectEvent(ctx, solrPrometheusExporter, 
corev1.EventTypeWarning, "InvalidConfigMap")
+               })
+       })
+})
diff --git a/controllers/solr_cluster_ops_util.go 
b/controllers/solr_cluster_ops_util.go
index deecd21..20d509e 100644
--- a/controllers/solr_cluster_ops_util.go
+++ b/controllers/solr_cluster_ops_util.go
@@ -169,19 +169,15 @@ func determinePvcExpansionClusterOpLockIfNecessary(ctx 
context.Context, r *SolrC
        if e != nil {
                err = e
                logger.Error(err, "Could not parse the existing minimum PVC 
size from the StatefulSet annotation", "annotation", 
util.StorageMinimumSizeAnnotation, "value", oldSizeStr)
-               if r.Recorder != nil {
-                       r.Recorder.Eventf(instance, corev1.EventTypeWarning, 
"PVCExpansionError",
-                               "Could not parse the existing minimum data PVC 
size %q recorded on the StatefulSet: %v", oldSizeStr, e)
-               }
+               r.Recorder.Eventf(instance, corev1.EventTypeWarning, 
"PVCExpansionError",
+                       "Could not parse the existing minimum data PVC size %q 
recorded on the StatefulSet: %v", oldSizeStr, e)
                return
        }
        // PVCs cannot be shrunk, so only proceed if the new size is strictly 
bigger than the recorded size.
        if newSize.Cmp(oldSize) <= 0 {
                logger.Info("Cannot shrink existing data PVCs; ignoring the 
decreased storage request", "currentSize", oldSize.String(), "requestedSize", 
newSize.String())
-               if r.Recorder != nil {
-                       r.Recorder.Eventf(instance, corev1.EventTypeWarning, 
"PVCExpansionForbidden",
-                               "Cannot shrink data PersistentVolumeClaims from 
%s to %s; PersistentVolumeClaims can only be expanded.", oldSize.String(), 
newSize.String())
-               }
+               r.Recorder.Eventf(instance, corev1.EventTypeWarning, 
"PVCExpansionForbidden",
+                       "Cannot shrink data PersistentVolumeClaims from %s to 
%s; PersistentVolumeClaims can only be expanded.", oldSize.String(), 
newSize.String())
                return
        }
        // Pre-flight: make sure the storage class backing the data PVCs allows 
volume expansion. If it
@@ -192,10 +188,8 @@ func determinePvcExpansionClusterOpLockIfNecessary(ctx 
context.Context, r *SolrC
                logger.Error(scErr, "Could not verify whether the storage class 
allows volume expansion; proceeding with the expansion attempt")
        } else if !allowed {
                logger.Info("Storage class does not allow volume expansion; 
ignoring the increased storage request", "storageClass", className, 
"currentSize", oldSize.String(), "requestedSize", newSize.String())
-               if r.Recorder != nil {
-                       r.Recorder.Eventf(instance, corev1.EventTypeWarning, 
"PVCExpansionForbidden",
-                               "Storage class %q does not allow volume 
expansion (allowVolumeExpansion); cannot expand data PersistentVolumeClaims 
from %s to %s.", className, oldSize.String(), newSize.String())
-               }
+               r.Recorder.Eventf(instance, corev1.EventTypeWarning, 
"PVCExpansionForbidden",
+                       "Storage class %q does not allow volume expansion 
(allowVolumeExpansion); cannot expand data PersistentVolumeClaims from %s to 
%s.", className, oldSize.String(), newSize.String())
                return
        }
        clusterOp = &SolrClusterOp{
@@ -211,6 +205,8 @@ func handlePvcExpansion(ctx context.Context, r 
*SolrCloudReconciler, instance *s
        newSize, err = resource.ParseQuantity(clusterOp.Metadata)
        if err != nil {
                logger.Error(err, "Could not convert PvcExpansion metadata to a 
resource.Quantity, as it represents the new size of PVCs", "metadata", 
clusterOp.Metadata)
+               r.Recorder.Eventf(instance, corev1.EventTypeWarning, 
"PVCExpansionError",
+                       "Could not parse the target PVC size %q from the 
cluster operation metadata: %v", clusterOp.Metadata, err)
                return
        }
        var resizeInfeasible bool
@@ -235,11 +231,9 @@ func handlePvcExpansion(ctx context.Context, r 
*SolrCloudReconciler, instance *s
                        // The storage backend has declared the requested size 
infeasible. There is nothing the
                        // operator can do until the user lowers the requested 
size, so surface it as an event and
                        // back off significantly instead of retrying tightly.
-                       if r.Recorder != nil {
-                               r.Recorder.Eventf(instance, 
corev1.EventTypeWarning, "PVCExpansionInfeasible",
-                                       "The storage backend reported that 
expanding the data PersistentVolumeClaims to %s is infeasible (e.g. it exceeds 
backend or quota limits). Reduce the requested storage size to a feasible value 
to recover.",
-                                       newSize.String())
-                       }
+                       r.Recorder.Eventf(instance, corev1.EventTypeWarning, 
"PVCExpansionInfeasible",
+                               "The storage backend reported that expanding 
the data PersistentVolumeClaims to %s is infeasible (e.g. it exceeds backend or 
quota limits). Reduce the requested storage size to a feasible value to 
recover.",
+                               newSize.String())
                        retryLaterDuration = time.Minute
                } else {
                        retryLaterDuration = time.Second * 5
@@ -292,6 +286,8 @@ func determineScaleClusterOpLockIfNecessary(ctx 
context.Context, r *SolrCloudRec
                                Metadata:  strconv.Itoa(desiredPods),
                        }
                } else {
+                       r.Recorder.Eventf(instance, corev1.EventTypeNormal, 
"ScalingUnmanaged",
+                               "Scaling SolrCloud from %d to %d pods without 
managed replica migration", configuredPods, desiredPods)
                        err = scaleCloudUnmanaged(ctx, r, statefulSet, 
desiredPods, logger)
                }
        } else if scaleDownOpIsQueued {
@@ -312,8 +308,9 @@ func handleManagedCloudScaleDown(ctx context.Context, r 
*SolrCloudReconciler, in
        var scaleDownTo int
        if scaleDownTo, err = strconv.Atoi(clusterOp.Metadata); err != nil {
                logger.Error(err, "Could not convert ScaleDown metadata to int, 
as it represents the number of nodes to scale to", "metadata", 
clusterOp.Metadata)
+               r.Recorder.Eventf(instance, corev1.EventTypeWarning, 
"ClusterOperationError",
+                       "Could not parse the scale-down target %q from the 
cluster operation metadata: %v", clusterOp.Metadata, err)
                return
-               // TODO: Create event for the CRD.
        }
 
        if len(podList) <= scaleDownTo {
@@ -365,7 +362,7 @@ func handleManagedCloudScaleDown(ctx context.Context, r 
*SolrCloudReconciler, in
 
 // cleanupManagedCloudScaleDown does the logic of cleaning-up an incomplete 
scale down operation.
 // This will remove any bad readinessConditions that the scaleDown might have 
set when trying to scaleDown pods.
-func cleanupManagedCloudScaleDown(ctx context.Context, r *SolrCloudReconciler, 
podList []corev1.Pod, logger logr.Logger) (err error) {
+func cleanupManagedCloudScaleDown(ctx context.Context, r *SolrCloudReconciler, 
instance *solrv1beta1.SolrCloud, podList []corev1.Pod, logger logr.Logger) (err 
error) {
        // First though, the scaleDown op might have set some pods to be 
"unready" before deletion. Undo that.
        // Before doing anything to the pod, make sure that the pods do not 
have a stopped readiness condition
        readinessConditions := 
map[corev1.PodConditionType]podReadinessConditionChange{
@@ -376,7 +373,7 @@ func cleanupManagedCloudScaleDown(ctx context.Context, r 
*SolrCloudReconciler, p
                },
        }
        for _, pod := range podList {
-               if updatedPod, e := EnsurePodReadinessConditions(ctx, r, &pod, 
readinessConditions, logger); e != nil {
+               if updatedPod, e := EnsurePodReadinessConditions(ctx, r, 
instance, &pod, readinessConditions, logger); e != nil {
                        err = e
                        return
                } else {
@@ -393,6 +390,8 @@ func handleManagedCloudScaleUp(ctx context.Context, r 
*SolrCloudReconciler, inst
        desiredPods, err = strconv.Atoi(clusterOp.Metadata)
        if err != nil {
                logger.Error(err, "Could not convert ScaleUp metadata to int, 
as it represents the number of nodes to scale to", "metadata", 
clusterOp.Metadata)
+               r.Recorder.Eventf(instance, corev1.EventTypeWarning, 
"ClusterOperationError",
+                       "Could not parse the scale-up target %q from the 
cluster operation metadata: %v", clusterOp.Metadata, err)
                return
        }
        configuredPods := int(*statefulSet.Spec.Replicas)
@@ -487,6 +486,8 @@ func handleManagedCloudRollingUpdate(ctx context.Context, r 
*SolrCloudReconciler
                // a restart to get a working pod config.
                state, retryLater, apiError := util.GetNodeReplicaState(ctx, 
instance, statefulSet, hasReadyPod, logger)
                if apiError != nil {
+                       r.Recorder.Eventf(instance, corev1.EventTypeWarning, 
"ClusterStateError",
+                               "Could not fetch the Solr cluster state needed 
to safely perform a rolling update: %v", apiError)
                        return false, true, 0, nil, apiError
                } else if !retryLater {
                        // If the cluster status has been successfully fetched, 
then add the pods scheduled for deletion
@@ -526,7 +527,7 @@ func handleManagedCloudRollingUpdate(ctx context.Context, r 
*SolrCloudReconciler
 
 // cleanupManagedCloudRollingUpdate does the logic of cleaning-up an 
incomplete rolling update operation.
 // This will remove any bad readinessConditions that the rollingUpdate might 
have set when trying to restart pods.
-func cleanupManagedCloudRollingUpdate(ctx context.Context, r 
*SolrCloudReconciler, podList []corev1.Pod, logger logr.Logger) (err error) {
+func cleanupManagedCloudRollingUpdate(ctx context.Context, r 
*SolrCloudReconciler, instance *solrv1beta1.SolrCloud, podList []corev1.Pod, 
logger logr.Logger) (err error) {
        // First though, the scaleDown op might have set some pods to be 
"unready" before deletion. Undo that.
        // Before doing anything to the pod, make sure that the pods do not 
have a stopped readiness condition
        er := EvictingReplicas
@@ -546,7 +547,7 @@ func cleanupManagedCloudRollingUpdate(ctx context.Context, 
r *SolrCloudReconcile
                },
        }
        for _, pod := range podList {
-               if updatedPod, e := EnsurePodReadinessConditions(ctx, r, &pod, 
readinessConditions, logger); e != nil {
+               if updatedPod, e := EnsurePodReadinessConditions(ctx, r, 
instance, &pod, readinessConditions, logger); e != nil {
                        err = e
                        return
                } else {
@@ -635,7 +636,7 @@ func evictAllPods(ctx context.Context, r 
*SolrCloudReconciler, instance *solrv1b
        }
 
        for i, pod := range podList {
-               if updatedPod, e := EnsurePodReadinessConditions(ctx, r, &pod, 
readinessConditions, logger); e != nil {
+               if updatedPod, e := EnsurePodReadinessConditions(ctx, r, 
instance, &pod, readinessConditions, logger); e != nil {
                        err = e
                        return
                } else {
@@ -676,7 +677,7 @@ func evictSinglePod(ctx context.Context, r 
*SolrCloudReconciler, instance *solrv
                return !podHasReplicas, false, errors.New("Could not find pod " 
+ podName + " when trying to migrate replicas to scale down pod.")
        }
 
-       if updatedPod, e := EnsurePodReadinessConditions(ctx, r, pod, 
readinessConditions, logger); e != nil {
+       if updatedPod, e := EnsurePodReadinessConditions(ctx, r, instance, pod, 
readinessConditions, logger); e != nil {
                err = e
                return
        } else {
@@ -685,7 +686,7 @@ func evictSinglePod(ctx context.Context, r 
*SolrCloudReconciler, instance *solrv
 
        // Only evict from the pod if it contains replicas in the clusterState
        var canDeletePod bool
-       if err, canDeletePod, requestInProgress = 
util.EvictReplicasForPodIfNecessary(ctx, instance, pod, podHasReplicas, 
"scaleDown", logger); err != nil {
+       if err, canDeletePod, requestInProgress = 
util.EvictReplicasForPodIfNecessary(ctx, instance, pod, podHasReplicas, 
"scaleDown", r.Recorder, logger); err != nil {
                logger.Error(err, "Error while evicting replicas on Pod, when 
scaling down SolrCloud", "pod", pod.Name)
        } else if canDeletePod {
                // The pod previously had replicas, so loop back in the next 
reconcile to make sure that the pod doesn't
diff --git a/controllers/solr_pod_lifecycle_util.go 
b/controllers/solr_pod_lifecycle_util.go
index 84116ad..c794f07 100644
--- a/controllers/solr_pod_lifecycle_util.go
+++ b/controllers/solr_pod_lifecycle_util.go
@@ -64,7 +64,7 @@ func DeletePodForUpdate(ctx context.Context, r 
*SolrCloudReconciler, instance *s
                        status:              false,
                },
        }
-       if updatedPod, e := EnsurePodReadinessConditions(ctx, r, pod, 
podStoppedReadinessConditions, logger); e != nil {
+       if updatedPod, e := EnsurePodReadinessConditions(ctx, r, instance, pod, 
podStoppedReadinessConditions, logger); e != nil {
                err = e
                return
        } else {
@@ -75,7 +75,7 @@ func DeletePodForUpdate(ctx context.Context, r 
*SolrCloudReconciler, instance *s
        deletePod := false
        if PodConditionEquals(pod, 
util.SolrReplicasNotEvictedReadinessCondition, EvictingReplicas) {
                // Only evict pods that contain replicas in the clusterState
-               if evictError, canDeletePod, inProgTmp := 
util.EvictReplicasForPodIfNecessary(ctx, instance, pod, podHasReplicas, 
"podUpdate", logger); evictError != nil {
+               if evictError, canDeletePod, inProgTmp := 
util.EvictReplicasForPodIfNecessary(ctx, instance, pod, podHasReplicas, 
"podUpdate", r.Recorder, logger); evictError != nil {
                        requestInProgress = true
                        err = evictError
                        logger.Error(err, "Error while evicting replicas on 
pod", "pod", pod.Name)
@@ -107,15 +107,18 @@ func DeletePodForUpdate(ctx context.Context, r 
*SolrCloudReconciler, instance *s
                })
                if err != nil {
                        logger.Error(err, "Error while killing solr pod for 
update", "pod", pod.Name)
+                       r.Recorder.Eventf(instance, corev1.EventTypeWarning, 
"PodUpdateError",
+                               "Error while deleting pod %s for an update: 
%v", pod.Name, err)
+               } else {
+                       r.Recorder.Eventf(instance, corev1.EventTypeNormal, 
"PodUpdate",
+                               "Deleting pod %s so that it can be recreated 
with the updated SolrCloud specification", pod.Name)
                }
-
-               // TODO: Create event for the CRD.
        }
 
        return
 }
 
-func EnsurePodReadinessConditions(ctx context.Context, r *SolrCloudReconciler, 
pod *corev1.Pod, ensureConditions 
map[corev1.PodConditionType]podReadinessConditionChange, logger logr.Logger) 
(updatedPod *corev1.Pod, err error) {
+func EnsurePodReadinessConditions(ctx context.Context, r *SolrCloudReconciler, 
instance *solrv1beta1.SolrCloud, pod *corev1.Pod, ensureConditions 
map[corev1.PodConditionType]podReadinessConditionChange, logger logr.Logger) 
(updatedPod *corev1.Pod, err error) {
        updatedPod = pod.DeepCopy()
 
        needsUpdate := false
@@ -137,7 +140,8 @@ func EnsurePodReadinessConditions(ctx context.Context, r 
*SolrCloudReconciler, p
                        logger.Error(err, "Could not patch readiness 
condition(s) for pod to stop traffic", "pod", pod.Name)
                        updatedPod = pod
 
-                       // TODO: Create event for the CRD.
+                       r.Recorder.Eventf(instance, corev1.EventTypeWarning, 
"PodReadinessConditionUpdateFailed",
+                               "Could not patch readiness condition(s) on pod 
%s to stop traffic: %v", pod.Name, err)
                }
        } else {
                updatedPod = pod
diff --git a/controllers/solrbackup_controller.go 
b/controllers/solrbackup_controller.go
index 1e8c188..7d9c244 100644
--- a/controllers/solrbackup_controller.go
+++ b/controllers/solrbackup_controller.go
@@ -29,11 +29,13 @@ import (
 
        "github.com/apache/solr-operator/controllers/util"
        "github.com/go-logr/logr"
+       corev1 "k8s.io/api/core/v1"
        "k8s.io/apimachinery/pkg/api/errors"
        metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
        "k8s.io/apimachinery/pkg/runtime"
        "k8s.io/apimachinery/pkg/types"
        "k8s.io/client-go/rest"
+       "k8s.io/client-go/tools/record"
        ctrl "sigs.k8s.io/controller-runtime"
        "sigs.k8s.io/controller-runtime/pkg/client"
        "sigs.k8s.io/controller-runtime/pkg/log"
@@ -45,8 +47,9 @@ import (
 // SolrBackupReconciler reconciles a SolrBackup object
 type SolrBackupReconciler struct {
        client.Client
-       Scheme *runtime.Scheme
-       Config *rest.Config
+       Scheme   *runtime.Scheme
+       Config   *rest.Config
+       Recorder record.EventRecorder
 }
 
 //+kubebuilder:rbac:groups="",resources=pods/exec,verbs=create
@@ -129,6 +132,8 @@ func (r *SolrBackupReconciler) Reconcile(ctx 
context.Context, req ctrl.Request)
                if err1 != nil {
                        // TODO Should we be failing the backup for some 
sub-set of errors here?
                        logger.Error(err1, "Error while taking SolrCloud 
backup")
+                       r.Recorder.Eventf(backup, corev1.EventTypeWarning, 
"BackupError",
+                               "Error while taking backup of SolrCloud %q: 
%v", backup.Spec.SolrCloud, err1)
 
                        // Requeue after 10 seconds for errors.
                        updateRequeueAfter(&requeueOrNot, time.Second*10)
@@ -136,6 +141,14 @@ func (r *SolrBackupReconciler) Reconcile(ctx 
context.Context, req ctrl.Request)
                        // Set finish time
                        now := metav1.Now()
                        backup.Status.IndividualSolrBackupStatus.FinishTime = 
&now
+
+                       if successful := 
backup.Status.IndividualSolrBackupStatus.Successful; successful != nil && 
*successful {
+                               r.Recorder.Eventf(backup, 
corev1.EventTypeNormal, "BackupSucceeded",
+                                       "The backup of SolrCloud %q completed 
successfully", backup.Spec.SolrCloud)
+                       } else {
+                               r.Recorder.Eventf(backup, 
corev1.EventTypeWarning, "BackupFailed",
+                                       "The backup of SolrCloud %q finished 
but was not successful", backup.Spec.SolrCloud)
+                       }
                } else if solrCloud != nil {
                        // When working with the collection backups, 
auto-requeue after 5 seconds
                        // to check on the status of the async solr backup calls
@@ -147,12 +160,16 @@ func (r *SolrBackupReconciler) Reconcile(ctx 
context.Context, req ctrl.Request)
        if backup.Status.IndividualSolrBackupStatus.Finished && 
backup.Spec.Recurrence.IsEnabled() {
                if nextBackupTime, err1 := 
util.ScheduleNextBackup(backup.Spec.Recurrence.Schedule, 
backup.Status.IndividualSolrBackupStatus.StartTime.Time); err1 != nil {
                        logger.Error(err1, "Could not update backup scheduling 
due to bad cron schedule", "cron", backup.Spec.Recurrence.Schedule)
+                       r.Recorder.Eventf(backup, corev1.EventTypeWarning, 
"BackupScheduleInvalid",
+                               "Could not schedule the next recurring backup 
due to an invalid cron schedule %q: %v", backup.Spec.Recurrence.Schedule, err1)
                } else {
                        convTime := metav1.NewTime(nextBackupTime)
                        if backup.Status.NextScheduledTime == nil || convTime 
!= *backup.Status.NextScheduledTime {
                                // Only log out the message if there is a 
change in NextScheduled
                                logger.Info("(Re)scheduling Next Backup", 
"time", nextBackupTime)
                                backup.Status.NextScheduledTime = &convTime
+                               r.Recorder.Eventf(backup, 
corev1.EventTypeNormal, "BackupRescheduled",
+                                       "Scheduling the next recurring backup 
of SolrCloud %q for %s", backup.Spec.SolrCloud, 
nextBackupTime.UTC().Format(time.RFC3339))
                                updateRequeueAfter(&requeueOrNot, 
backup.Status.NextScheduledTime.Sub(time.Now()))
                        }
                }
@@ -214,12 +231,16 @@ func (r *SolrBackupReconciler) 
reconcileSolrCloudBackup(ctx context.Context, bac
                // Make sure that all solr living Solr pods have the backupRepo 
configured
                if 
!solrCloud.Status.BackupRepositoriesAvailable[backupRepository.Name] {
                        logger.Info("Cloud not ready for backup", "solrCloud", 
solrCloud.Name, "repository", backupRepository.Name)
+                       r.Recorder.Eventf(backup, corev1.EventTypeWarning, 
"BackupCloudNotReady",
+                               "SolrCloud %q is not yet ready for backups in 
the %q repository", solrCloud.Name, backupRepository.Name)
                        return solrCloud, actionTaken, 
errors.NewServiceUnavailable(fmt.Sprintf("Cloud is not ready for backups in the 
%s repository", backupRepository.Name))
                }
 
                // Only set the solr version at the start of the backup. This 
shouldn't change throughout the backup.
                currentBackupStatus.SolrVersion = solrCloud.Status.Version
                currentBackupStatus.StartTime = metav1.Now()
+               r.Recorder.Eventf(backup, corev1.EventTypeNormal, 
"BackupStarted",
+                       "Started backup of SolrCloud %q to the %q repository", 
solrCloud.Name, backupRepository.Name)
        }
 
        collectionsToBackup := backup.Spec.Collections
@@ -229,6 +250,8 @@ func (r *SolrBackupReconciler) reconcileSolrCloudBackup(ctx 
context.Context, bac
                collectionsToBackup, err = util.ListAllSolrCollections(ctx, 
solrCloud, logger)
                if err != nil {
                        logger.Error(err, "Error listing collections", 
"solrCloud", solrCloud.Name)
+                       r.Recorder.Eventf(backup, corev1.EventTypeWarning, 
"BackupCollectionListError",
+                               "Error listing the collections to back up for 
SolrCloud %q: %v", solrCloud.Name, err)
                }
        }
 
@@ -311,7 +334,9 @@ func reconcileSolrCollectionBackup(ctx context.Context, 
backup *solrv1beta1.Solr
 // SetupWithManager sets up the controller with the Manager.
 func (r *SolrBackupReconciler) SetupWithManager(mgr ctrl.Manager) (err error) {
        r.Config = mgr.GetConfig()
-
+       if r.Recorder == nil {
+               r.Recorder = noOpEventRecorder{}
+       }
        ctrlBuilder := ctrl.NewControllerManagedBy(mgr).
                For(&solrv1beta1.SolrBackup{})
 
diff --git a/controllers/solrcloud_controller.go 
b/controllers/solrcloud_controller.go
index e94ef7b..5a09b62 100644
--- a/controllers/solrcloud_controller.go
+++ b/controllers/solrcloud_controller.go
@@ -69,6 +69,7 @@ func UseZkCRD(useCRD bool) {
 
 //+kubebuilder:rbac:groups="",resources=pods,verbs=get;list;watch;delete
 //+kubebuilder:rbac:groups="",resources=pods/status,verbs=get;patch
+//+kubebuilder:rbac:groups="",resources=events,verbs=create;patch
 
//+kubebuilder:rbac:groups="",resources=services,verbs=get;list;watch;create;update;patch;delete
 //+kubebuilder:rbac:groups="",resources=services/status,verbs=get
 
//+kubebuilder:rbac:groups=apps,resources=statefulsets,verbs=get;list;watch;create;update;patch;delete
@@ -172,7 +173,8 @@ func (r *SolrCloudReconciler) Reconcile(ctx 
context.Context, req ctrl.Request) (
                        if 
instance.Spec.SolrAddressability.External.UseExternalAddress {
                                if ip == "" {
                                        // If we are using this IP in the 
hostAliases of the statefulSet, it needs to be set for every service before 
trying to update the statefulSet
-                                       // TODO: Make an event here
+                                       r.Recorder.Eventf(instance, 
corev1.EventTypeWarning, "NodeServiceAddressNotReady",
+                                               "Waiting for the 
Kubernetes-assigned address (clusterIP) of node service %q before the 
StatefulSet can be reconciled, since the SolrCloud advertises its external 
address via host aliases", nodeName)
                                        blockReconciliationOfStatefulSet = true
                                } else {
                                        
hostNameIpMap[instance.AdvertisedNodeHost(nodeName)] = ip
@@ -230,7 +232,8 @@ func (r *SolrCloudReconciler) Reconcile(ctx 
context.Context, req ctrl.Request) (
 
                        // if there's a user-provided config, it must have one 
of the expected keys
                        if !hasLogXml && !hasSolrXml {
-                               // TODO: Create event for the CRD.
+                               r.Recorder.Eventf(instance, 
corev1.EventTypeWarning, "InvalidConfigMap",
+                                       "User provided ConfigMap %s must have 
one of 'solr.xml' and/or 'log4j2.xml'", providedConfigMapName)
                                return requeueOrNot, fmt.Errorf("user provided 
ConfigMap %s must have one of 'solr.xml' and/or 'log4j2.xml'",
                                        providedConfigMapName)
                        }
@@ -238,6 +241,8 @@ func (r *SolrCloudReconciler) Reconcile(ctx 
context.Context, req ctrl.Request) (
                        if hasSolrXml {
                                // make sure the user-provided solr.xml is valid
                                if !(strings.Contains(solrXml, 
"${solr.port.advertise:") || strings.Contains(solrXml, "${hostPort:")) {
+                                       r.Recorder.Eventf(instance, 
corev1.EventTypeWarning, "InvalidConfigMap",
+                                               "Custom solr.xml in ConfigMap 
%s must contain a placeholder for either 'solr.port.advertise' or its 
deprecated alternative 'hostPort'", providedConfigMapName)
                                        return requeueOrNot,
                                                fmt.Errorf("custom solr.xml in 
ConfigMap %s must contain a placeholder for either 'solr.port.advertise', or 
its deprecated alternative 'hostPort', e.g. <int 
name=\"hostPort\">${solr.port.advertise:80}</int>",
                                                        providedConfigMapName)
@@ -256,6 +261,8 @@ func (r *SolrCloudReconciler) Reconcile(ctx 
context.Context, req ctrl.Request) (
                        }
 
                } else {
+                       r.Recorder.Eventf(instance, corev1.EventTypeWarning, 
"InvalidConfigMap",
+                               "Provided ConfigMap %s has no data", 
providedConfigMapName)
                        return requeueOrNot, fmt.Errorf("provided ConfigMap %s 
has no data", providedConfigMapName)
                }
        }
@@ -349,7 +356,8 @@ func (r *SolrCloudReconciler) Reconcile(ctx 
context.Context, req ctrl.Request) (
                        if nextRestartAnnotation != "" {
                                // Set the new restart time annotation
                                
expectedStatefulSet.Spec.Template.Annotations[util.SolrScheduledRestartAnnotation]
 = nextRestartAnnotation
-                               // TODO: Create event for the CRD.
+                               r.Recorder.Eventf(instance, 
corev1.EventTypeNormal, "RestartScheduled",
+                                       "Scheduling next restart of the Solr 
StatefulSet for %s, as configured by the updateStrategy.restartSchedule", 
nextRestartAnnotation)
                        } else if existingRestartAnnotation, exists := 
foundStatefulSet.Spec.Template.Annotations[util.SolrScheduledRestartAnnotation];
 exists {
                                // Keep the existing nextRestart annotation if 
it exists and we aren't setting a new one.
                                
expectedStatefulSet.Spec.Template.Annotations[util.SolrScheduledRestartAnnotation]
 = existingRestartAnnotation
@@ -508,6 +516,8 @@ func (r *SolrCloudReconciler) Reconcile(ctx 
context.Context, req ctrl.Request) (
                        operationFound = false
                        // This shouldn't happen, but we don't want to be stuck 
if it does.
                        // Just remove the cluster Op, because the solr 
operator version running does not support it.
+                       r.Recorder.Eventf(instance, corev1.EventTypeWarning, 
"ClusterOperationUnsupported",
+                               "Removing cluster operation %q because it is 
not supported by this version of the Solr Operator", 
string(clusterOp.Operation))
                        err = clearClusterOpLockWithPatch(ctx, r, statefulSet, 
"clusterOp not supported", logger)
                }
                if operationFound {
@@ -521,7 +531,10 @@ func (r *SolrCloudReconciler) Reconcile(ctx 
context.Context, req ctrl.Request) (
                                        err = 
setNextClusterOpLockWithPatch(ctx, r, statefulSet, nextClusterOperation, 
string(clusterOp.Operation)+" complete", logger)
                                }
 
-                               // TODO: Create event for the CRD.
+                               if err == nil {
+                                       r.Recorder.Eventf(instance, 
corev1.EventTypeNormal, "ClusterOperationComplete",
+                                               "Completed cluster operation %q 
on the SolrCloud", string(clusterOp.Operation))
+                               }
                        } else if !requestInProgress {
                                // If the cluster operation is in a stoppable 
place (not currently doing an async operation), and either:
                                //   - the operation hit an error and has taken 
more than 1 minute
@@ -543,15 +556,16 @@ func (r *SolrCloudReconciler) Reconcile(ctx 
context.Context, req ctrl.Request) (
                                        // If the operation is being queued, 
first have the operation cleanup after itself
                                        switch clusterOp.Operation {
                                        case UpdateLock:
-                                               err = 
cleanupManagedCloudRollingUpdate(ctx, r, outOfDatePods.ScheduledForDeletion, 
logger)
+                                               err = 
cleanupManagedCloudRollingUpdate(ctx, r, instance, 
outOfDatePods.ScheduledForDeletion, logger)
                                        case ScaleDownLock:
-                                               err = 
cleanupManagedCloudScaleDown(ctx, r, podList, logger)
+                                               err = 
cleanupManagedCloudScaleDown(ctx, r, instance, podList, logger)
                                        }
                                        if err == nil {
                                                err = 
enqueueCurrentClusterOpForRetryWithPatch(ctx, r, statefulSet, 
string(clusterOp.Operation)+" "+queueForLaterReason, logger)
                                        }
 
-                                       // TODO: Create event for the CRD.
+                                       r.Recorder.Eventf(instance, 
corev1.EventTypeWarning, "ClusterOperationRetry",
+                                               "Pausing cluster operation %q 
because it %s; it has been queued to be retried later", 
string(clusterOp.Operation), queueForLaterReason)
                                }
                        }
                }
@@ -612,6 +626,8 @@ func (r *SolrCloudReconciler) Reconcile(ctx 
context.Context, req ctrl.Request) (
                                        logger.Error(err, "Error while patching 
StatefulSet to start locked clusterOp", clusterOp.Operation, 
"clusterOpMetadata", clusterOp.Metadata)
                                } else {
                                        logger.Info("Started locked clusterOp", 
"clusterOp", clusterOp.Operation, "clusterOpMetadata", clusterOp.Metadata)
+                                       r.Recorder.Eventf(instance, 
corev1.EventTypeNormal, "ClusterOperationStarted",
+                                               "Starting cluster operation %q 
on the SolrCloud", string(clusterOp.Operation))
                                }
                        } else {
                                // No new clusterOperation has been started, 
retry the next queued clusterOp, if there are any operations in the retry queue.
@@ -787,7 +803,7 @@ func (r *SolrCloudReconciler) initializePods(ctx 
context.Context, solrCloud *sol
                if !isOwnedByCurrentStatefulSet {
                        continue
                }
-               if updatedPod, podError := r.initializePod(ctx, &pod, logger); 
podError != nil {
+               if updatedPod, podError := r.initializePod(ctx, solrCloud, 
&pod, logger); podError != nil {
                        err = podError
                } else if updatedPod != nil {
                        podList = append(podList, *updatedPod)
@@ -797,7 +813,7 @@ func (r *SolrCloudReconciler) initializePods(ctx 
context.Context, solrCloud *sol
 }
 
 // InitializePod Initialize Status Conditions for a SolrCloud Pod
-func (r *SolrCloudReconciler) initializePod(ctx context.Context, pod 
*corev1.Pod, logger logr.Logger) (updatedPod *corev1.Pod, err error) {
+func (r *SolrCloudReconciler) initializePod(ctx context.Context, instance 
*solrv1beta1.SolrCloud, pod *corev1.Pod, logger logr.Logger) (updatedPod 
*corev1.Pod, err error) {
        shouldPatchPod := false
 
        updatedPod = pod.DeepCopy()
@@ -815,7 +831,8 @@ func (r *SolrCloudReconciler) initializePod(ctx 
context.Context, pod *corev1.Pod
                        // set the pod back to its original state since the 
patch failed
                        updatedPod = pod
 
-                       // TODO: Create event for the CRD.
+                       r.Recorder.Eventf(instance, corev1.EventTypeWarning, 
"PodReadinessConditionUpdateFailed",
+                               "Could not patch readiness condition(s) on pod 
%s to start traffic: %v", pod.Name, err)
                }
        }
        return
@@ -1342,6 +1359,9 @@ func (r *SolrCloudReconciler) reconcileTLSConfig(instance 
*solrv1beta1.SolrCloud
 
 // SetupWithManager sets up the controller with the Manager.
 func (r *SolrCloudReconciler) SetupWithManager(mgr ctrl.Manager) error {
+       if r.Recorder == nil {
+               r.Recorder = noOpEventRecorder{}
+       }
        ctrlBuilder := ctrl.NewControllerManagedBy(mgr).
                For(&solrv1beta1.SolrCloud{}).
                Owns(&corev1.ConfigMap{}).
diff --git a/controllers/solrprometheusexporter_controller.go 
b/controllers/solrprometheusexporter_controller.go
index e9c886c..1251208 100644
--- a/controllers/solrprometheusexporter_controller.go
+++ b/controllers/solrprometheusexporter_controller.go
@@ -28,6 +28,7 @@ import (
        "k8s.io/apimachinery/pkg/fields"
        "k8s.io/apimachinery/pkg/runtime"
        "k8s.io/apimachinery/pkg/types"
+       "k8s.io/client-go/tools/record"
        ctrl "sigs.k8s.io/controller-runtime"
        "sigs.k8s.io/controller-runtime/pkg/builder"
        "sigs.k8s.io/controller-runtime/pkg/client"
@@ -43,7 +44,8 @@ import (
 // SolrPrometheusExporterReconciler reconciles a SolrPrometheusExporter object
 type SolrPrometheusExporterReconciler struct {
        client.Client
-       Scheme *runtime.Scheme
+       Scheme   *runtime.Scheme
+       Recorder record.EventRecorder
 }
 
 
//+kubebuilder:rbac:groups=,resources=configmaps,verbs=get;list;watch;create;update;patch;delete
@@ -104,10 +106,14 @@ func (r *SolrPrometheusExporterReconciler) Reconcile(ctx 
context.Context, req ct
                        if ok {
                                configXmlMd5 = fmt.Sprintf("%x", 
md5.Sum([]byte(configXml)))
                        } else {
+                               r.Recorder.Eventf(prometheusExporter, 
corev1.EventTypeWarning, "InvalidConfigMap",
+                                       "Provided ConfigMap %s must contain the 
required %q key", 
prometheusExporter.Spec.CustomKubeOptions.ConfigMapOptions.ProvidedConfigMap, 
configMapKey)
                                return requeueOrNot, fmt.Errorf("required '%s' 
key not found in provided ConfigMap %s",
                                        configMapKey, 
prometheusExporter.Spec.CustomKubeOptions.ConfigMapOptions.ProvidedConfigMap)
                        }
                } else {
+                       r.Recorder.Eventf(prometheusExporter, 
corev1.EventTypeWarning, "InvalidConfigMap",
+                               "Provided ConfigMap %s has no data", 
prometheusExporter.Spec.CustomKubeOptions.ConfigMapOptions.ProvidedConfigMap)
                        return requeueOrNot, fmt.Errorf("provided ConfigMap %s 
has no data",
                                
prometheusExporter.Spec.CustomKubeOptions.ConfigMapOptions.ProvidedConfigMap)
                }
@@ -224,7 +230,8 @@ func (r *SolrPrometheusExporterReconciler) Reconcile(ctx 
context.Context, req ct
                        }
                        // Set the new restart time annotation
                        
deploy.Spec.Template.Annotations[util.SolrScheduledRestartAnnotation] = 
nextRestartAnnotation
-                       // TODO: Create event for the CRD.
+                       r.Recorder.Eventf(prometheusExporter, 
corev1.EventTypeNormal, "RestartScheduled",
+                               "Scheduling next restart of the Prometheus 
Exporter Deployment for %s, as configured by the restartSchedule", 
nextRestartAnnotation)
                } else if existingRestartAnnotation, exists := 
foundDeploy.Spec.Template.Annotations[util.SolrScheduledRestartAnnotation]; 
exists {
                        if deploy.Spec.Template.Annotations == nil {
                                deploy.Spec.Template.Annotations = 
make(map[string]string, 1)
@@ -341,6 +348,9 @@ func (r *SolrPrometheusExporterReconciler) 
reconcileTLSConfig(prometheusExporter
 
 // SetupWithManager sets up the controller with the Manager.
 func (r *SolrPrometheusExporterReconciler) SetupWithManager(mgr ctrl.Manager) 
error {
+       if r.Recorder == nil {
+               r.Recorder = noOpEventRecorder{}
+       }
        ctrlBuilder := ctrl.NewControllerManagedBy(mgr).
                For(&solrv1beta1.SolrPrometheusExporter{}).
                Owns(&corev1.ConfigMap{}).
diff --git a/controllers/suite_test.go b/controllers/suite_test.go
index 7b89ee8..81209a9 100644
--- a/controllers/suite_test.go
+++ b/controllers/suite_test.go
@@ -108,17 +108,19 @@ var _ = BeforeSuite(func(ctx context.Context) {
        Expect((&SolrCloudReconciler{
                Client:   k8sManager.GetClient(),
                Scheme:   k8sManager.GetScheme(),
-               Recorder: 
k8sManager.GetEventRecorderFor("solrcloud-controller"),
+               Recorder: k8sManager.GetEventRecorderFor("solr-operator"),
        }).SetupWithManager(k8sManager)).To(Succeed())
 
        Expect((&SolrPrometheusExporterReconciler{
-               Client: k8sManager.GetClient(),
-               Scheme: k8sManager.GetScheme(),
+               Client:   k8sManager.GetClient(),
+               Scheme:   k8sManager.GetScheme(),
+               Recorder: k8sManager.GetEventRecorderFor("solr-operator"),
        }).SetupWithManager(k8sManager)).To(Succeed())
 
        Expect((&SolrBackupReconciler{
-               Client: k8sManager.GetClient(),
-               Scheme: k8sManager.GetScheme(),
+               Client:   k8sManager.GetClient(),
+               Scheme:   k8sManager.GetScheme(),
+               Recorder: k8sManager.GetEventRecorderFor("solr-operator"),
        }).SetupWithManager(k8sManager)).To(Succeed())
 
        go func() {
diff --git a/controllers/util/solr_update_util.go 
b/controllers/util/solr_update_util.go
index 3123544..647ef13 100644
--- a/controllers/util/solr_update_util.go
+++ b/controllers/util/solr_update_util.go
@@ -27,6 +27,7 @@ import (
        appsv1 "k8s.io/api/apps/v1"
        corev1 "k8s.io/api/core/v1"
        "k8s.io/apimachinery/pkg/util/intstr"
+       "k8s.io/client-go/tools/record"
        "net/url"
        "sort"
        "strings"
@@ -548,7 +549,7 @@ func GetManagedSolrNodeNames(solrCloud *solr.SolrCloud, 
currentlyConfiguredPodCo
 // EvictReplicasForPodIfNecessary takes a solr Pod and migrates all replicas 
off of that Pod.
 // For updates this will only be called for pods using ephemeral data.
 // For scale-down operations, this can be called for pods using ephemeral or 
persistent data.
-func EvictReplicasForPodIfNecessary(ctx context.Context, solrCloud 
*solr.SolrCloud, pod *corev1.Pod, podHasReplicas bool, evictionReason string, 
logger logr.Logger) (err error, canDeletePod bool, requestInProgress bool) {
+func EvictReplicasForPodIfNecessary(ctx context.Context, solrCloud 
*solr.SolrCloud, pod *corev1.Pod, podHasReplicas bool, evictionReason string, 
recorder record.EventRecorder, logger logr.Logger) (err error, canDeletePod 
bool, requestInProgress bool) {
        logger = logger.WithValues("evictionReason", evictionReason)
        // If the Cloud has 1 or zero pods, and this is the "-0" pod, then 
delete the data since we can't move it anywhere else
        // Otherwise, move the replicas to other pods
@@ -581,6 +582,8 @@ func EvictReplicasForPodIfNecessary(ctx context.Context, 
solrCloud *solr.SolrClo
                                }
                                if err == nil {
                                        logger.Info("Migrating all replicas off 
of pod before deletion.", "requestId", requestId, "pod", pod.Name)
+                                       recorder.Eventf(solrCloud, 
corev1.EventTypeNormal, "ReplicaMigrationStarted",
+                                               "Migrating all replicas off of 
pod %s before deletion (%s)", pod.Name, evictionReason)
                                        requestInProgress = true
                                } else {
                                        logger.Error(err, "Could not migrate 
all replicas off of pod before deletion. Will try again.")
@@ -595,8 +598,12 @@ func EvictReplicasForPodIfNecessary(ctx context.Context, 
solrCloud *solr.SolrClo
                        if asyncState == "completed" {
                                canDeletePod = true
                                logger.Info("Migration of all replicas off of 
pod before deletion complete. Pod can now be deleted.", "pod", pod.Name)
+                               recorder.Eventf(solrCloud, 
corev1.EventTypeNormal, "ReplicaMigrationComplete",
+                                       "Migration of all replicas off of pod 
%s is complete; the pod can now be deleted (%s)", pod.Name, evictionReason)
                        } else if asyncState == "failed" {
                                logger.Info("Migration of all replicas off of 
pod before deletion failed. Will try again.", "pod", pod.Name, "message", 
message)
+                               recorder.Eventf(solrCloud, 
corev1.EventTypeWarning, "ReplicaMigrationFailed",
+                                       "Migration of all replicas off of pod 
%s failed and will be retried: %s", pod.Name, message)
                        } else {
                                requestInProgress = true
                        }
diff --git a/helm/solr-operator/Chart.yaml b/helm/solr-operator/Chart.yaml
index a214d50..90794fb 100644
--- a/helm/solr-operator/Chart.yaml
+++ b/helm/solr-operator/Chart.yaml
@@ -62,6 +62,13 @@ annotations:
           url: https://github.com/apache/solr-operator/issues/709
         - name: Github PR
           url: https://github.com/apache/solr-operator/pull/712
+    - kind: added
+      description: The Solr Operator now creates events for Solr resources, 
giving users much more transparency on what is happening behind the scenes.
+      links:
+        - name: Github Issue
+          url: https://github.com/apache/solr-operator/issues/120
+        - name: Github PR
+          url: https://github.com/apache/solr-operator/pull/836
     - kind: changed
       description: A container PostStart Hook is no longer used to create the 
ZooKeeper ChRoot, instead the initContainer will manage this
       links:
diff --git a/helm/solr-operator/templates/role.yaml 
b/helm/solr-operator/templates/role.yaml
index 6a267a0..861ad0c 100644
--- a/helm/solr-operator/templates/role.yaml
+++ b/helm/solr-operator/templates/role.yaml
@@ -43,6 +43,13 @@ rules:
   - services/status
   verbs:
   - get
+- apiGroups:
+  - ""
+  resources:
+  - events
+  verbs:
+  - create
+  - patch
 - apiGroups:
   - ""
   resources:
diff --git a/main.go b/main.go
index d504995..27003df 100644
--- a/main.go
+++ b/main.go
@@ -201,22 +201,24 @@ func main() {
        if err = (&controllers.SolrCloudReconciler{
                Client:   mgr.GetClient(),
                Scheme:   mgr.GetScheme(),
-               Recorder: mgr.GetEventRecorderFor("solrcloud-controller"),
+               Recorder: mgr.GetEventRecorderFor("solr-operator"),
        }).SetupWithManager(mgr); err != nil {
                setupLog.Error(err, "unable to create controller", 
"controller", "SolrCloud")
                os.Exit(1)
        }
        if err = (&controllers.SolrPrometheusExporterReconciler{
-               Client: mgr.GetClient(),
-               Scheme: mgr.GetScheme(),
+               Client:   mgr.GetClient(),
+               Scheme:   mgr.GetScheme(),
+               Recorder: mgr.GetEventRecorderFor("solr-operator"),
        }).SetupWithManager(mgr); err != nil {
                setupLog.Error(err, "unable to create controller", 
"controller", "SolrPrometheusExporter")
                os.Exit(1)
        }
        if err = (&controllers.SolrBackupReconciler{
-               Client: mgr.GetClient(),
-               Scheme: mgr.GetScheme(),
-               Config: mgr.GetConfig(),
+               Client:   mgr.GetClient(),
+               Scheme:   mgr.GetScheme(),
+               Config:   mgr.GetConfig(),
+               Recorder: mgr.GetEventRecorderFor("solr-operator"),
        }).SetupWithManager(mgr); err != nil {
                setupLog.Error(err, "unable to create controller", 
"controller", "SolrBackup")
                os.Exit(1)
diff --git a/tests/e2e/backups_test.go b/tests/e2e/backups_test.go
index 7c3c7e1..0e41de3 100644
--- a/tests/e2e/backups_test.go
+++ b/tests/e2e/backups_test.go
@@ -119,6 +119,10 @@ var _ = FDescribe("E2E - Backups", Ordered, func() {
                        
Expect(foundSolrBackup.Status.History).To(HaveLen(solrBackup.Spec.Recurrence.MaxSaved),
 "The SolrBackup does not have the correct number of saved backups in its 
status")
                        
Expect(foundSolrBackup.Status.History[len(foundSolrBackup.Status.History)-1].Successful).To(PointTo(BeTrue()),
 "The latest backup was not successful")
 
+                       By("checking that recurring backup events were recorded 
on the SolrBackup")
+                       expectEvent(ctx, solrBackup, corev1.EventTypeNormal, 
"BackupStarted")
+                       expectEvent(ctx, solrBackup, corev1.EventTypeNormal, 
"BackupRescheduled")
+
                        lastBackupId := 0
                        checkBackup(ctx, solrCloud, solrBackup, func(g Gomega, 
collection string, backupListResponse *solr_api.SolrBackupListResponse) {
                                
g.Expect(backupListResponse.Backups).To(HaveLen(3), "The wrong number of 
recurring backups have been saved")
@@ -156,6 +160,10 @@ var _ = FDescribe("E2E - Backups", Ordered, func() {
                                
g.Expect(backup.Status.Successful).To(PointTo(BeTrue()), "Backup did not 
successfully complete")
                        })
 
+                       By("checking that backup lifecycle events were recorded 
on the SolrBackup")
+                       expectEvent(ctx, solrBackup, corev1.EventTypeNormal, 
"BackupStarted")
+                       expectEvent(ctx, solrBackup, corev1.EventTypeNormal, 
"BackupSucceeded")
+
                        checkBackup(ctx, solrCloud, solrBackup, func(g Gomega, 
collection string, backupListResponse *solr_api.SolrBackupListResponse) {
                                
g.Expect(backupListResponse.Backups).To(HaveLen(1), "A non-recurring backupList 
should have a length of 1")
                        })
diff --git a/tests/e2e/resource_utils_test.go b/tests/e2e/resource_utils_test.go
index 31b88c6..bef49ef 100644
--- a/tests/e2e/resource_utils_test.go
+++ b/tests/e2e/resource_utils_test.go
@@ -53,6 +53,26 @@ func resourceKey(parentResource client.Object, name string) 
types.NamespacedName
        return types.NamespacedName{Name: name, Namespace: 
parentResource.GetNamespace()}
 }
 
+// expectEvent waits until at least one Kubernetes Event of the given type and 
reason has been
+// recorded against the given object. Events are matched on the involved 
object's UID so that
+// events left over from previous specs (Events are not garbage-collected with 
the object) cannot
+// cause a false positive.
+func expectEvent(ctx context.Context, parentResource client.Object, eventType 
string, reason string, additionalOffset ...int) {
+       EventuallyWithOffset(resolveOffset(additionalOffset), func(g Gomega) {
+               eventList := &corev1.EventList{}
+               g.Expect(k8sClient.List(ctx, eventList, 
client.InNamespace(parentResource.GetNamespace()))).To(Succeed())
+               matched := false
+               for i := range eventList.Items {
+                       e := &eventList.Items[i]
+                       if e.InvolvedObject.UID == parentResource.GetUID() && 
e.Type == eventType && e.Reason == reason {
+                               matched = true
+                               break
+                       }
+               }
+               g.Expect(matched).To(BeTrue(), "Expected a %q event with reason 
%q to be recorded on %s/%s", eventType, reason, parentResource.GetNamespace(), 
parentResource.GetName())
+       }).Should(Succeed())
+}
+
 func deleteAndWait(ctx context.Context, object client.Object, additionalOffset 
...int) {
        key := resourceKey(object, object.GetName())
        kinds, _, err := k8sClient.Scheme().ObjectKinds(object)
diff --git a/tests/e2e/solrcloud_rolling_upgrade_test.go 
b/tests/e2e/solrcloud_rolling_upgrade_test.go
index c56951d..55b6d3b 100644
--- a/tests/e2e/solrcloud_rolling_upgrade_test.go
+++ b/tests/e2e/solrcloud_rolling_upgrade_test.go
@@ -24,6 +24,7 @@ import (
        . "github.com/onsi/ginkgo/v2"
        . "github.com/onsi/gomega"
        appsv1 "k8s.io/api/apps/v1"
+       corev1 "k8s.io/api/core/v1"
        "k8s.io/apimachinery/pkg/util/intstr"
        "sigs.k8s.io/controller-runtime/pkg/client"
        "time"
@@ -172,6 +173,10 @@ var _ = FDescribe("E2E - SolrCloud - Rolling Upgrades", 
func() {
                        By("checking that the collections can be queried after 
the restart")
                        queryCollection(ctx, solrCloud, solrCollection1, 0)
                        queryCollection(ctx, solrCloud, solrCollection2, 0)
+
+                       By("checking that rolling update cluster-operation 
events were recorded on the SolrCloud")
+                       expectEvent(ctx, solrCloud, corev1.EventTypeNormal, 
"ClusterOperationStarted")
+                       expectEvent(ctx, solrCloud, corev1.EventTypeNormal, 
"ClusterOperationComplete")
                })
        })
 })
diff --git a/tests/e2e/solrcloud_scaling_test.go 
b/tests/e2e/solrcloud_scaling_test.go
index 26bba2a..78ac80f 100644
--- a/tests/e2e/solrcloud_scaling_test.go
+++ b/tests/e2e/solrcloud_scaling_test.go
@@ -137,6 +137,12 @@ var _ = FDescribe("E2E - SolrCloud - Scale Down", func() {
                        expectNoPod(ctx, solrCloud, solrCloud.GetSolrPodName(1))
                        queryCollection(ctx, solrCloud, solrCollection1, 0)
                        queryCollection(ctx, solrCloud, solrCollection2, 0)
+
+                       By("checking that managed scale-down events were 
recorded on the SolrCloud")
+                       expectEvent(ctx, solrCloud, corev1.EventTypeNormal, 
"ClusterOperationStarted")
+                       expectEvent(ctx, solrCloud, corev1.EventTypeNormal, 
"ReplicaMigrationStarted")
+                       expectEvent(ctx, solrCloud, corev1.EventTypeNormal, 
"ReplicaMigrationComplete")
+                       expectEvent(ctx, solrCloud, corev1.EventTypeNormal, 
"ClusterOperationComplete")
                })
        })
 
@@ -306,6 +312,9 @@ var _ = FDescribe("E2E - SolrCloud - Scale Down", func() {
 
                        expectNoPod(ctx, solrCloud, solrCloud.GetSolrPodName(1))
                        queryCollectionWithNoReplicaAvailable(ctx, solrCloud, 
solrCollection1)
+
+                       By("checking that an unmanaged scaling event was 
recorded on the SolrCloud")
+                       expectEvent(ctx, solrCloud, corev1.EventTypeNormal, 
"ScalingUnmanaged")
                })
        })
 })
@@ -388,6 +397,10 @@ var _ = FDescribe("E2E - SolrCloud - Scale Up", func() {
 
                        queryCollection(ctx, solrCloud, solrCollection1, 0)
                        queryCollection(ctx, solrCloud, solrCollection2, 0)
+
+                       By("checking that managed scale-up events were recorded 
on the SolrCloud")
+                       expectEvent(ctx, solrCloud, corev1.EventTypeNormal, 
"ClusterOperationStarted")
+                       expectEvent(ctx, solrCloud, corev1.EventTypeNormal, 
"ClusterOperationComplete")
                })
        })
 

Reply via email to