Merge "[operator] Add Receiver CustomResource"
diff --git a/operator/README.md b/operator/README.md
index beeb79a..2f7116a 100644
--- a/operator/README.md
+++ b/operator/README.md
@@ -76,6 +76,9 @@
 
 ### Gerrit
 
+**NOTE:** A primary Gerrit should never be installed in the same GerritCluster as a
+Receiver to avoid conflicts when writing into repositories.
+
 An example of a Gerrit-CustomResource can be found at `k8s/gerrit.sample.yaml`.
 To install it into the cluster run:
 
@@ -96,6 +99,21 @@
 
 The operator will create a CronJob based on the provided spec.
 
+### Receiver
+
+**NOTE:** A Receiver should never be installed in the same GerritCluster as a
+primary Gerrit to avoid conflicts when writing into repositories.
+
+An example of a Receiver-CustomResource can be found at `k8s/receiver.sample.yaml`.
+To install it into the cluster run:
+
+```sh
+kubectl apply -f k8s/receiver.sample.yaml
+```
+
+The operator will create all resources to run a receiver for push replication
+requests.
+
 ## Configure custom resources
 
 ### GerritCluster
@@ -450,3 +468,103 @@
     #       values:
     #       - ssd
 ```
+
+### Receiver
+
+```yaml
+apiVersion: "gerritoperator.google.com/v1alpha1"
+kind: Receiver
+metadata:
+  name: receiver
+spec:
+  ## Name of the Gerrit cluster this Gerrit is a part of. (mandatory)
+  cluster: gerrit
+
+  ## Pod tolerations (https://kubernetes.io/docs/concepts/scheduling-eviction/taint-and-toleration/)
+  ## (optional)
+  tolerations: []
+  # - key: "key1"
+  #   operator: "Equal"
+  #   value: "value1"
+  #   effect: "NoSchedule"
+
+  ## Pod affinity (https://kubernetes.io/docs/tasks/configure-pod-container/assign-pods-nodes-using-node-affinity/)
+  ## (optional)
+  affinity: {}
+    # nodeAffinity:
+    # requiredDuringSchedulingIgnoredDuringExecution:
+    #   nodeSelectorTerms:
+    #   - matchExpressions:
+    #     - key: disktype
+    #       operator: In
+    #       values:
+    #       - ssd
+
+  ## Pod topology spread constraints (https://kubernetes.io/docs/concepts/scheduling-eviction/topology-spread-constraints/#:~:text=You%20can%20use%20topology%20spread,well%20as%20efficient%20resource%20utilization.)
+  ## (optional)
+  topologySpreadConstraints: []
+  # - maxSkew: 1
+  #   topologyKey: zone
+  #   whenUnsatisfiable: DoNotSchedule
+  #   labelSelector:
+  #     matchLabels:
+  #       foo: bar
+
+  ## PriorityClass to be used with the pod (https://kubernetes.io/docs/concepts/scheduling-eviction/pod-priority-preemption/)
+  ## (optional)
+  priorityClassName: ""
+
+  ## Number of pods running Gerrit in the StatefulSet (default: 1)
+  replicas: 1
+
+  ## Ordinal or percentage of pods that are allowed to be created in addition during
+  ## rolling updates. (default: 1)
+  maxSurge: 1
+
+  ## Ordinal or percentage of pods that are allowed to be unavailable during
+  ## rolling updates. (default: 1)
+  maxUnavailable: 1
+
+  ## Resource requests/limits of the receiver container
+  ## (https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/)
+  ## (optional)
+  resources: {}
+    # requests:
+    #   cpu: 1
+    #   memory: 5Gi
+    # limits:
+    #   cpu: 1
+    #   memory: 6Gi
+
+  ## Readiness probe (https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/#configure-probes)
+  ## The action will be set by the operator. All other probe parameters can be set.
+  ## (optional)
+  readinessProbe: {}
+    # initialDelaySeconds: 0
+    # periodSeconds: 10
+    # timeoutSeconds: 1
+    # successThreshold: 1
+    # failureThreshold: 3
+
+  ## Liveness probe (https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/#configure-probes)
+  ## The action will be set by the operator. All other probe parameters can be set.
+  ## (optional)
+  livenessProbe: {}
+    # initialDelaySeconds: 0
+    # periodSeconds: 10
+    # timeoutSeconds: 1
+    # successThreshold: 1
+    # failureThreshold: 3
+
+  ## Configuration for the service used to manage network access to the StatefulSet
+  service:
+    ## Service type (default: NodePort)
+    type: NodePort
+
+    ## Port used for HTTP requests (default: 80)
+    httpPort: 80
+
+  ## Name of the secret containing the .htpasswd file used to configure basic
+  ## authentication within the Apache server (mandatory)
+  credentialSecretRef: null
+```
diff --git a/operator/k8s/operator.yaml b/operator/k8s/operator.yaml
index 76857aa..9ab055f 100644
--- a/operator/k8s/operator.yaml
+++ b/operator/k8s/operator.yaml
@@ -73,6 +73,7 @@
   - "apps"
   resources:
   - statefulsets
+  - deployments
   verbs:
   - '*'
 - apiGroups:
diff --git a/operator/k8s/receiver.sample.yaml b/operator/k8s/receiver.sample.yaml
new file mode 100644
index 0000000..b992495
--- /dev/null
+++ b/operator/k8s/receiver.sample.yaml
@@ -0,0 +1,17 @@
+apiVersion: "gerritoperator.google.com/v1alpha1"
+kind: Receiver
+metadata:
+  name: receiver
+spec:
+  cluster: gerrit
+  credentialSecretRef: receiver-htpasswd
+---
+apiVersion: v1
+kind: Secret
+metadata:
+  name:  receiver-htpasswd
+  labels:
+    app: gerrit
+data:
+  .htpasswd: Z2l0OiRhcHIxJE8vTGJMS0M3JFE2MEdXRTdPY3FTRU1TZmUvSzh4VS4=
+type: Opaque
diff --git a/operator/src/main/java/com/google/gerrit/k8s/operator/GerritOperator.java b/operator/src/main/java/com/google/gerrit/k8s/operator/GerritOperator.java
index e28c073..c630846 100644
--- a/operator/src/main/java/com/google/gerrit/k8s/operator/GerritOperator.java
+++ b/operator/src/main/java/com/google/gerrit/k8s/operator/GerritOperator.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.k8s.operator.cluster.GerritClusterReconciler;
 import com.google.gerrit.k8s.operator.gerrit.GerritReconciler;
 import com.google.gerrit.k8s.operator.gitgc.GitGarbageCollectionReconciler;
+import com.google.gerrit.k8s.operator.receiver.ReceiverReconciler;
 import io.fabric8.kubernetes.client.Config;
 import io.fabric8.kubernetes.client.ConfigBuilder;
 import io.fabric8.kubernetes.client.KubernetesClient;
@@ -42,6 +43,8 @@
     operator.register(new GitGarbageCollectionReconciler(client));
     logger.atFine().log("Registering Gerrit Reconciler");
     operator.register(new GerritReconciler(client));
+    logger.atFine().log("Registering Receiver Reconciler");
+    operator.register(new ReceiverReconciler(client));
     operator.installShutdownHook();
     operator.start();
 
diff --git a/operator/src/main/java/com/google/gerrit/k8s/operator/cluster/GerritCluster.java b/operator/src/main/java/com/google/gerrit/k8s/operator/cluster/GerritCluster.java
index 98f6232..1fb8e0e 100644
--- a/operator/src/main/java/com/google/gerrit/k8s/operator/cluster/GerritCluster.java
+++ b/operator/src/main/java/com/google/gerrit/k8s/operator/cluster/GerritCluster.java
@@ -19,7 +19,6 @@
 import static com.google.gerrit.k8s.operator.cluster.NfsIdmapdConfigMap.NFS_IDMAPD_CM_NAME;
 
 import com.fasterxml.jackson.annotation.JsonIgnore;
-import com.google.gerrit.k8s.operator.gerrit.Gerrit;
 import io.fabric8.kubernetes.api.model.Container;
 import io.fabric8.kubernetes.api.model.ContainerBuilder;
 import io.fabric8.kubernetes.api.model.EnvVar;
@@ -134,8 +133,9 @@
   }
 
   @JsonIgnore
-  public static boolean isGerritInstancePartOfCluster(Gerrit gerrit, GerritCluster cluster) {
-    return gerrit.getSpec().getCluster().equals(cluster.getMetadata().getName());
+  public static boolean isMemberPartOfCluster(
+      GerritClusterMemberSpec memberSpec, GerritCluster cluster) {
+    return memberSpec.getCluster().equals(cluster.getMetadata().getName());
   }
 
   @JsonIgnore
diff --git a/operator/src/main/java/com/google/gerrit/k8s/operator/cluster/GerritClusterMember.java b/operator/src/main/java/com/google/gerrit/k8s/operator/cluster/GerritClusterMember.java
new file mode 100644
index 0000000..f458e59
--- /dev/null
+++ b/operator/src/main/java/com/google/gerrit/k8s/operator/cluster/GerritClusterMember.java
@@ -0,0 +1,23 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.k8s.operator.cluster;
+
+import io.fabric8.kubernetes.api.model.Namespaced;
+import io.fabric8.kubernetes.client.CustomResource;
+
+public abstract class GerritClusterMember<S extends GerritClusterMemberSpec, T>
+    extends CustomResource<S, T> implements Namespaced {
+  private static final long serialVersionUID = 1L;
+}
diff --git a/operator/src/main/java/com/google/gerrit/k8s/operator/cluster/GerritClusterMemberSpec.java b/operator/src/main/java/com/google/gerrit/k8s/operator/cluster/GerritClusterMemberSpec.java
new file mode 100644
index 0000000..7fe7d15
--- /dev/null
+++ b/operator/src/main/java/com/google/gerrit/k8s/operator/cluster/GerritClusterMemberSpec.java
@@ -0,0 +1,21 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.k8s.operator.cluster;
+
+public interface GerritClusterMemberSpec {
+  public String getCluster();
+
+  public void setCluster(String cluster);
+}
diff --git a/operator/src/main/java/com/google/gerrit/k8s/operator/cluster/GerritClusterReconciler.java b/operator/src/main/java/com/google/gerrit/k8s/operator/cluster/GerritClusterReconciler.java
index cf177b3..8b02dbe 100644
--- a/operator/src/main/java/com/google/gerrit/k8s/operator/cluster/GerritClusterReconciler.java
+++ b/operator/src/main/java/com/google/gerrit/k8s/operator/cluster/GerritClusterReconciler.java
@@ -17,6 +17,7 @@
 import static com.google.gerrit.k8s.operator.cluster.GerritClusterReconciler.PVC_EVENT_SOURCE;
 
 import com.google.gerrit.k8s.operator.gerrit.Gerrit;
+import com.google.gerrit.k8s.operator.receiver.Receiver;
 import io.fabric8.kubernetes.api.model.PersistentVolumeClaim;
 import io.fabric8.kubernetes.client.KubernetesClient;
 import io.javaoperatorsdk.operator.api.config.informer.InformerConfiguration;
@@ -31,6 +32,8 @@
 import io.javaoperatorsdk.operator.processing.event.source.EventSource;
 import io.javaoperatorsdk.operator.processing.event.source.SecondaryToPrimaryMapper;
 import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource;
+import java.util.Collection;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.stream.Collectors;
@@ -82,47 +85,72 @@
                 .build(),
             context);
 
+    final SecondaryToPrimaryMapper<Receiver> receiverMapper =
+        (Receiver receiver) ->
+            context
+                .getPrimaryCache()
+                .list(
+                    gerritCluster ->
+                        gerritCluster
+                            .getMetadata()
+                            .getName()
+                            .equals(receiver.getSpec().getCluster()))
+                .map(ResourceID::fromResource)
+                .collect(Collectors.toSet());
+
+    InformerEventSource<Receiver, GerritCluster> receiverEventSource =
+        new InformerEventSource<>(
+            InformerConfiguration.from(Receiver.class, context)
+                .withSecondaryToPrimaryMapper(receiverMapper)
+                .build(),
+            context);
+
     InformerEventSource<PersistentVolumeClaim, GerritCluster> pvcEventSource =
         new InformerEventSource<>(
             InformerConfiguration.from(PersistentVolumeClaim.class, context).build(), context);
 
     Map<String, EventSource> eventSources =
-        EventSourceInitializer.nameEventSources(gerritEventSource);
+        EventSourceInitializer.nameEventSources(gerritEventSource, receiverEventSource);
     eventSources.put(PVC_EVENT_SOURCE, pvcEventSource);
     eventSources.put(GERRIT_INGRESS_EVENT_SOURCE, this.gerritIngress.initEventSource(context));
-
     return eventSources;
   }
 
   @Override
   public UpdateControl<GerritCluster> reconcile(
       GerritCluster gerritCluster, Context<GerritCluster> context) {
-    List<String> managedGerrits = getManagedGerritInstances(gerritCluster);
-    if (!managedGerrits.isEmpty() && gerritCluster.getSpec().getIngress().isEnabled()) {
+    Map<String, List<String>> members = new HashMap<>();
+    members.put("gerrit", getManagedMemberInstances(gerritCluster, Gerrit.class));
+    members.put("receiver", getManagedMemberInstances(gerritCluster, Receiver.class));
+    if (members.values().stream().flatMap(Collection::stream).count() > 0
+        && gerritCluster.getSpec().getIngress().isEnabled()) {
       this.gerritIngress.reconcile(gerritCluster, context);
     }
-    return UpdateControl.patchStatus(updateStatus(gerritCluster, managedGerrits));
+    return UpdateControl.patchStatus(updateStatus(gerritCluster, members));
   }
 
-  private GerritCluster updateStatus(GerritCluster gerritCluster, List<String> managedGerrits) {
+  private GerritCluster updateStatus(
+      GerritCluster gerritCluster, Map<String, List<String>> members) {
     GerritClusterStatus status = gerritCluster.getStatus();
     if (status == null) {
       status = new GerritClusterStatus();
     }
-    status.setManagedGerritInstances(managedGerrits);
+    status.setMembers(members);
     gerritCluster.setStatus(status);
     return gerritCluster;
   }
 
-  private List<String> getManagedGerritInstances(GerritCluster gerritCluster) {
+  private List<String> getManagedMemberInstances(
+      GerritCluster gerritCluster,
+      Class<? extends GerritClusterMember<? extends GerritClusterMemberSpec, ?>> clazz) {
     return kubernetesClient
-        .resources(Gerrit.class)
+        .resources(clazz)
         .inNamespace(gerritCluster.getMetadata().getNamespace())
         .list()
         .getItems()
         .stream()
-        .filter(gerrit -> GerritCluster.isGerritInstancePartOfCluster(gerrit, gerritCluster))
-        .map(gerrit -> gerrit.getMetadata().getName())
+        .filter(c -> GerritCluster.isMemberPartOfCluster(c.getSpec(), gerritCluster))
+        .map(c -> c.getMetadata().getName())
         .collect(Collectors.toList());
   }
 }
diff --git a/operator/src/main/java/com/google/gerrit/k8s/operator/cluster/GerritClusterStatus.java b/operator/src/main/java/com/google/gerrit/k8s/operator/cluster/GerritClusterStatus.java
index 5f45a7c..c35b328 100644
--- a/operator/src/main/java/com/google/gerrit/k8s/operator/cluster/GerritClusterStatus.java
+++ b/operator/src/main/java/com/google/gerrit/k8s/operator/cluster/GerritClusterStatus.java
@@ -15,15 +15,16 @@
 package com.google.gerrit.k8s.operator.cluster;
 
 import java.util.List;
+import java.util.Map;
 
 public class GerritClusterStatus {
-  private List<String> managedGerritInstances;
+  private Map<String, List<String>> members;
 
-  public List<String> getManagedGerritInstances() {
-    return managedGerritInstances;
+  public Map<String, List<String>> getMembers() {
+    return members;
   }
 
-  public void setManagedGerritInstances(List<String> managedGerritInstances) {
-    this.managedGerritInstances = managedGerritInstances;
+  public void setMembers(Map<String, List<String>> members) {
+    this.members = members;
   }
 }
diff --git a/operator/src/main/java/com/google/gerrit/k8s/operator/cluster/GerritIngress.java b/operator/src/main/java/com/google/gerrit/k8s/operator/cluster/GerritIngress.java
index cf3589b..9d3aabd 100644
--- a/operator/src/main/java/com/google/gerrit/k8s/operator/cluster/GerritIngress.java
+++ b/operator/src/main/java/com/google/gerrit/k8s/operator/cluster/GerritIngress.java
@@ -14,10 +14,10 @@
 
 package com.google.gerrit.k8s.operator.cluster;
 
-import static com.google.gerrit.k8s.operator.gerrit.ServiceDependentResource.HTTP_PORT_NAME;
-
 import com.google.gerrit.k8s.operator.gerrit.Gerrit;
 import com.google.gerrit.k8s.operator.gerrit.ServiceDependentResource;
+import com.google.gerrit.k8s.operator.receiver.Receiver;
+import com.google.gerrit.k8s.operator.receiver.ReceiverServiceDependentResource;
 import io.fabric8.kubernetes.api.model.networking.v1.HTTPIngressPath;
 import io.fabric8.kubernetes.api.model.networking.v1.HTTPIngressPathBuilder;
 import io.fabric8.kubernetes.api.model.networking.v1.Ingress;
@@ -33,6 +33,7 @@
 import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Set;
 import java.util.stream.Collectors;
 
 @KubernetesDependent(labelSelector = "app.kubernetes.io/component=gerrit-ingress")
@@ -52,9 +53,31 @@
             .list()
             .getItems()
             .stream()
-            .filter(gerrit -> GerritCluster.isGerritInstancePartOfCluster(gerrit, gerritCluster))
+            .filter(gerrit -> GerritCluster.isMemberPartOfCluster(gerrit.getSpec(), gerritCluster))
             .collect(Collectors.toList());
 
+    List<Receiver> receivers =
+        client
+            .resources(Receiver.class)
+            .inNamespace(gerritCluster.getMetadata().getNamespace())
+            .list()
+            .getItems()
+            .stream()
+            .filter(r -> GerritCluster.isMemberPartOfCluster(r.getSpec(), gerritCluster))
+            .collect(Collectors.toList());
+
+    List<String> hosts = new ArrayList<>();
+    List<IngressRule> ingressRules = new ArrayList<>();
+    for (Receiver receiver : receivers) {
+      ingressRules.add(getReceiverIngressRule(gerritCluster, receiver));
+      hosts.add(getFullHostname(receiver.getMetadata().getName(), gerritCluster));
+    }
+
+    ingressRules.addAll(getGerritIngressRules(gerritCluster, gerrits));
+    for (Gerrit gerrit : gerrits) {
+      hosts.add(getFullHostname(gerrit.getMetadata().getName(), gerritCluster));
+    }
+
     Ingress gerritIngress =
         new IngressBuilder()
             .withNewMetadata()
@@ -64,20 +87,16 @@
             .withAnnotations(gerritCluster.getSpec().getIngress().getAnnotations())
             .endMetadata()
             .withNewSpec()
-            .withTls(getIngressTLS(gerritCluster, gerrits))
-            .withRules(getIngressRules(gerritCluster, gerrits))
+            .withTls(getIngressTLS(gerritCluster, hosts))
+            .withRules(ingressRules)
             .endSpec()
             .build();
 
     return gerritIngress;
   }
 
-  private IngressTLS getIngressTLS(GerritCluster gerritCluster, List<Gerrit> gerrits) {
+  private IngressTLS getIngressTLS(GerritCluster gerritCluster, List<String> hosts) {
     if (gerritCluster.getSpec().getIngress().getTls().isEnabled()) {
-      List<String> hosts = new ArrayList<>();
-      for (Gerrit gerrit : gerrits) {
-        hosts.add(getFullHostname(ServiceDependentResource.getName(gerrit), gerritCluster));
-      }
       return new IngressTLSBuilder()
           .withHosts(hosts)
           .withSecretName(gerritCluster.getSpec().getIngress().getTls().getSecret())
@@ -86,24 +105,36 @@
     return new IngressTLS();
   }
 
-  private List<IngressRule> getIngressRules(GerritCluster gerritCluster, List<Gerrit> gerrits) {
+  private List<IngressRule> getGerritIngressRules(
+      GerritCluster gerritCluster, List<Gerrit> gerrits) {
     List<IngressRule> ingressRules = new ArrayList<>();
 
     for (Gerrit gerrit : gerrits) {
-      String svcName = ServiceDependentResource.getName(gerrit);
+      String gerritSvcName = ServiceDependentResource.getName(gerrit);
       ingressRules.add(
           new IngressRuleBuilder()
-              .withHost(getFullHostname(svcName, gerritCluster))
+              .withHost(getFullHostname(gerritSvcName, gerritCluster))
               .withNewHttp()
-              .withPaths(getHTTPIngressPaths(svcName))
+              .withPaths(getGerritHTTPIngressPath(gerritSvcName))
               .endHttp()
               .build());
     }
+
     return ingressRules;
   }
 
-  public HTTPIngressPath getHTTPIngressPaths(String svcName) {
-    ServiceBackendPort port = new ServiceBackendPortBuilder().withName(HTTP_PORT_NAME).build();
+  private IngressRule getReceiverIngressRule(GerritCluster gerritCluster, Receiver receiver) {
+    return new IngressRuleBuilder()
+        .withHost(getFullHostname(receiver.getMetadata().getName(), gerritCluster))
+        .withNewHttp()
+        .withPaths(getReceiverIngressPaths(ReceiverServiceDependentResource.getName(receiver)))
+        .endHttp()
+        .build();
+  }
+
+  public HTTPIngressPath getGerritHTTPIngressPath(String svcName) {
+    ServiceBackendPort port =
+        new ServiceBackendPortBuilder().withName(ServiceDependentResource.HTTP_PORT_NAME).build();
 
     return new HTTPIngressPathBuilder()
         .withPathType("Prefix")
@@ -117,6 +148,29 @@
         .build();
   }
 
+  public List<HTTPIngressPath> getReceiverIngressPaths(String svcName) {
+    List<HTTPIngressPath> paths = new ArrayList<>();
+    ServiceBackendPort port =
+        new ServiceBackendPortBuilder()
+            .withName(ReceiverServiceDependentResource.HTTP_PORT_NAME)
+            .build();
+
+    for (String path : Set.of("/a/projects", "/new", "/git")) {
+      paths.add(
+          new HTTPIngressPathBuilder()
+              .withPathType("Prefix")
+              .withPath(path)
+              .withNewBackend()
+              .withNewService()
+              .withName(svcName)
+              .withPort(port)
+              .endService()
+              .endBackend()
+              .build());
+    }
+    return paths;
+  }
+
   public static String getFullHostname(String svcName, GerritCluster gerritCluster) {
     return String.format("%s.%s", svcName, gerritCluster.getSpec().getIngress().getHost());
   }
diff --git a/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/Gerrit.java b/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/Gerrit.java
index 1ca2cc4..92c676c 100644
--- a/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/Gerrit.java
+++ b/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/Gerrit.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.k8s.operator.gerrit;
 
+import com.google.gerrit.k8s.operator.cluster.GerritClusterMember;
 import io.fabric8.kubernetes.api.model.Namespaced;
-import io.fabric8.kubernetes.client.CustomResource;
 import io.fabric8.kubernetes.model.annotation.Group;
 import io.fabric8.kubernetes.model.annotation.ShortNames;
 import io.fabric8.kubernetes.model.annotation.Version;
@@ -25,7 +25,7 @@
 @Group("gerritoperator.google.com")
 @Version("v1alpha1")
 @ShortNames("gcr")
-public class Gerrit extends CustomResource<GerritSpec, GerritStatus> implements Namespaced {
+public class Gerrit extends GerritClusterMember<GerritSpec, GerritStatus> implements Namespaced {
   private static final long serialVersionUID = 1L;
 
   public String toString() {
diff --git a/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/GerritConfigMapDependentResource.java b/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/GerritConfigMapDependentResource.java
index 829eab8..58cfec4 100644
--- a/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/GerritConfigMapDependentResource.java
+++ b/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/GerritConfigMapDependentResource.java
@@ -85,7 +85,7 @@
         .build();
   }
 
-  protected static String getName(Gerrit gerrit) {
+  public static String getName(Gerrit gerrit) {
     return String.format("%s-configmap", gerrit.getMetadata().getName());
   }
 }
diff --git a/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/GerritInitConfigMapDependentResource.java b/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/GerritInitConfigMapDependentResource.java
index c52cd82..ab21184 100644
--- a/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/GerritInitConfigMapDependentResource.java
+++ b/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/GerritInitConfigMapDependentResource.java
@@ -91,7 +91,7 @@
     }
   }
 
-  protected static String getName(Gerrit gerrit) {
+  public static String getName(Gerrit gerrit) {
     return String.format("%s-init-configmap", gerrit.getMetadata().getName());
   }
 }
diff --git a/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/GerritSpec.java b/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/GerritSpec.java
index 56643b8..e3311dd 100644
--- a/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/GerritSpec.java
+++ b/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/GerritSpec.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.k8s.operator.gerrit;
 
+import com.google.gerrit.k8s.operator.cluster.GerritClusterMemberSpec;
 import io.fabric8.kubernetes.api.model.Affinity;
 import io.fabric8.kubernetes.api.model.ResourceRequirements;
 import io.fabric8.kubernetes.api.model.Toleration;
@@ -23,7 +24,7 @@
 import java.util.Map;
 import java.util.Set;
 
-public class GerritSpec {
+public class GerritSpec implements GerritClusterMemberSpec {
   private String cluster;
 
   private List<Toleration> tolerations;
diff --git a/operator/src/main/java/com/google/gerrit/k8s/operator/gitgc/GitGarbageCollection.java b/operator/src/main/java/com/google/gerrit/k8s/operator/gitgc/GitGarbageCollection.java
index b25912d..3fc7960 100644
--- a/operator/src/main/java/com/google/gerrit/k8s/operator/gitgc/GitGarbageCollection.java
+++ b/operator/src/main/java/com/google/gerrit/k8s/operator/gitgc/GitGarbageCollection.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.k8s.operator.gitgc;
 
+import com.google.gerrit.k8s.operator.cluster.GerritClusterMember;
 import io.fabric8.kubernetes.api.model.Namespaced;
-import io.fabric8.kubernetes.client.CustomResource;
 import io.fabric8.kubernetes.model.annotation.Group;
 import io.fabric8.kubernetes.model.annotation.Plural;
 import io.fabric8.kubernetes.model.annotation.ShortNames;
@@ -28,7 +28,7 @@
 @ShortNames("gitgc")
 @Plural("gitgcs")
 public class GitGarbageCollection
-    extends CustomResource<GitGarbageCollectionSpec, GitGarbageCollectionStatus>
+    extends GerritClusterMember<GitGarbageCollectionSpec, GitGarbageCollectionStatus>
     implements Namespaced {
   private static final long serialVersionUID = 1L;
 
diff --git a/operator/src/main/java/com/google/gerrit/k8s/operator/gitgc/GitGarbageCollectionSpec.java b/operator/src/main/java/com/google/gerrit/k8s/operator/gitgc/GitGarbageCollectionSpec.java
index fbd9458..d0c48f4 100644
--- a/operator/src/main/java/com/google/gerrit/k8s/operator/gitgc/GitGarbageCollectionSpec.java
+++ b/operator/src/main/java/com/google/gerrit/k8s/operator/gitgc/GitGarbageCollectionSpec.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.k8s.operator.gitgc;
 
+import com.google.gerrit.k8s.operator.cluster.GerritClusterMemberSpec;
 import io.fabric8.kubernetes.api.model.Affinity;
 import io.fabric8.kubernetes.api.model.ResourceRequirements;
 import io.fabric8.kubernetes.api.model.Toleration;
@@ -22,7 +23,7 @@
 import java.util.Objects;
 import java.util.Set;
 
-public class GitGarbageCollectionSpec {
+public class GitGarbageCollectionSpec implements GerritClusterMemberSpec {
   private String cluster;
   private String schedule;
   private Set<String> projects;
diff --git a/operator/src/main/java/com/google/gerrit/k8s/operator/receiver/Receiver.java b/operator/src/main/java/com/google/gerrit/k8s/operator/receiver/Receiver.java
new file mode 100644
index 0000000..758f382
--- /dev/null
+++ b/operator/src/main/java/com/google/gerrit/k8s/operator/receiver/Receiver.java
@@ -0,0 +1,35 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.k8s.operator.receiver;
+
+import com.google.gerrit.k8s.operator.cluster.GerritClusterMember;
+import io.fabric8.kubernetes.api.model.Namespaced;
+import io.fabric8.kubernetes.model.annotation.Group;
+import io.fabric8.kubernetes.model.annotation.ShortNames;
+import io.fabric8.kubernetes.model.annotation.Version;
+import org.apache.commons.lang3.builder.ToStringBuilder;
+import org.apache.commons.lang3.builder.ToStringStyle;
+
+@Group("gerritoperator.google.com")
+@Version("v1alpha1")
+@ShortNames("grec")
+public class Receiver extends GerritClusterMember<ReceiverSpec, ReceiverStatus>
+    implements Namespaced {
+  private static final long serialVersionUID = 1L;
+
+  public String toString() {
+    return ToStringBuilder.reflectionToString(this, ToStringStyle.JSON_STYLE);
+  }
+}
diff --git a/operator/src/main/java/com/google/gerrit/k8s/operator/receiver/ReceiverDeploymentDependentResource.java b/operator/src/main/java/com/google/gerrit/k8s/operator/receiver/ReceiverDeploymentDependentResource.java
new file mode 100644
index 0000000..b50fc5a
--- /dev/null
+++ b/operator/src/main/java/com/google/gerrit/k8s/operator/receiver/ReceiverDeploymentDependentResource.java
@@ -0,0 +1,175 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.k8s.operator.receiver;
+
+import com.google.gerrit.k8s.operator.cluster.GerritCluster;
+import com.google.gerrit.k8s.operator.cluster.NfsWorkaroundConfig;
+import io.fabric8.kubernetes.api.model.Container;
+import io.fabric8.kubernetes.api.model.ContainerPort;
+import io.fabric8.kubernetes.api.model.Volume;
+import io.fabric8.kubernetes.api.model.VolumeBuilder;
+import io.fabric8.kubernetes.api.model.VolumeMount;
+import io.fabric8.kubernetes.api.model.VolumeMountBuilder;
+import io.fabric8.kubernetes.api.model.apps.Deployment;
+import io.fabric8.kubernetes.api.model.apps.DeploymentBuilder;
+import io.javaoperatorsdk.operator.api.reconciler.Context;
+import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource;
+import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+@KubernetesDependent
+public class ReceiverDeploymentDependentResource
+    extends CRUDKubernetesDependentResource<Deployment, Receiver> {
+  public static final int HTTP_PORT = 80;
+
+  public ReceiverDeploymentDependentResource() {
+    super(Deployment.class);
+  }
+
+  @Override
+  protected Deployment desired(Receiver receiver, Context<Receiver> context) {
+    GerritCluster gerritCluster =
+        client
+            .resources(GerritCluster.class)
+            .inNamespace(receiver.getMetadata().getNamespace())
+            .withName(receiver.getSpec().getCluster())
+            .get();
+
+    if (gerritCluster == null) {
+      throw new IllegalStateException("The Gerrit cluster could not be found.");
+    }
+
+    DeploymentBuilder deploymentBuilder = new DeploymentBuilder();
+
+    List<Container> initContainers = new ArrayList<>();
+
+    NfsWorkaroundConfig nfsWorkaround =
+        gerritCluster.getSpec().getStorageClasses().getNfsWorkaround();
+    if (nfsWorkaround.isEnabled() && nfsWorkaround.isChownOnStartup()) {
+      initContainers.add(gerritCluster.createNfsInitContainer());
+    }
+
+    deploymentBuilder
+        .withApiVersion("apps/v1")
+        .withNewMetadata()
+        .withName(receiver.getMetadata().getName())
+        .withNamespace(receiver.getMetadata().getNamespace())
+        .withLabels(getLabels(gerritCluster, receiver))
+        .endMetadata()
+        .withNewSpec()
+        .withReplicas(receiver.getSpec().getReplicas())
+        .withNewStrategy()
+        .withNewRollingUpdate()
+        .withMaxSurge(receiver.getSpec().getMaxSurge())
+        .withMaxUnavailable(receiver.getSpec().getMaxUnavailable())
+        .endRollingUpdate()
+        .endStrategy()
+        .withNewSelector()
+        .withMatchLabels(getLabels(gerritCluster, receiver))
+        .endSelector()
+        .withNewTemplate()
+        .withNewMetadata()
+        .withLabels(getLabels(gerritCluster, receiver))
+        .endMetadata()
+        .withNewSpec()
+        .withTolerations(receiver.getSpec().getTolerations())
+        .withTopologySpreadConstraints(receiver.getSpec().getTopologySpreadConstraints())
+        .withAffinity(receiver.getSpec().getAffinity())
+        .withPriorityClassName(receiver.getSpec().getPriorityClassName())
+        .addAllToImagePullSecrets(gerritCluster.getSpec().getImagePullSecrets())
+        .withNewSecurityContext()
+        .withFsGroup(100L)
+        .endSecurityContext()
+        .addAllToInitContainers(initContainers)
+        .addNewContainer()
+        .withName("apache-git-http-backend")
+        .withImagePullPolicy(gerritCluster.getSpec().getImagePullPolicy())
+        .withImage(
+            gerritCluster.getSpec().getGerritImages().getFullImageName("apache-git-http-backend"))
+        .withEnv(GerritCluster.getPodNameEnvVar())
+        .withPorts(getContainerPorts(receiver))
+        .withResources(receiver.getSpec().getResources())
+        .withReadinessProbe(receiver.getSpec().getReadinessProbe())
+        .withLivenessProbe(receiver.getSpec().getLivenessProbe())
+        .addAllToVolumeMounts(getVolumeMounts(receiver, gerritCluster, false))
+        .endContainer()
+        .addAllToVolumes(getVolumes(receiver, gerritCluster))
+        .endSpec()
+        .endTemplate()
+        .endSpec();
+
+    return deploymentBuilder.build();
+  }
+
+  public static Map<String, String> getLabels(GerritCluster gerritCluster, Receiver receiver) {
+    return gerritCluster.getLabels(
+        String.format("receiver-deployment-%s", receiver.getMetadata().getName()),
+        ReceiverReconciler.class.getSimpleName());
+  }
+
+  private Set<Volume> getVolumes(Receiver receiver, GerritCluster gerritCluster) {
+    Set<Volume> volumes = new HashSet<>();
+    volumes.add(gerritCluster.getGitRepositoriesVolume());
+    volumes.add(gerritCluster.getLogsVolume());
+
+    volumes.add(
+        new VolumeBuilder()
+            .withName(receiver.getSpec().getCredentialSecretRef())
+            .withNewSecret()
+            .withSecretName(receiver.getSpec().getCredentialSecretRef())
+            .endSecret()
+            .build());
+
+    NfsWorkaroundConfig nfsWorkaround =
+        gerritCluster.getSpec().getStorageClasses().getNfsWorkaround();
+    if (nfsWorkaround.isEnabled() && nfsWorkaround.getIdmapdConfig() != null) {
+      volumes.add(gerritCluster.getNfsImapdConfigVolume());
+    }
+
+    return volumes;
+  }
+
+  private Set<VolumeMount> getVolumeMounts(
+      Receiver receiver, GerritCluster gerritCluster, boolean isInitContainer) {
+    Set<VolumeMount> volumeMounts = new HashSet<>();
+    volumeMounts.add(gerritCluster.getGitRepositoriesVolumeMount("/var/gerrit/git"));
+    volumeMounts.add(gerritCluster.getLogsVolumeMount("/var/log/apache2"));
+
+    volumeMounts.add(
+        new VolumeMountBuilder()
+            .withName(receiver.getSpec().getCredentialSecretRef())
+            .withMountPath("/var/apache/credentials/.htpasswd")
+            .withSubPath(".htpasswd")
+            .build());
+
+    NfsWorkaroundConfig nfsWorkaround =
+        gerritCluster.getSpec().getStorageClasses().getNfsWorkaround();
+    if (nfsWorkaround.isEnabled() && nfsWorkaround.getIdmapdConfig() != null) {
+      volumeMounts.add(gerritCluster.getNfsImapdConfigVolumeMount());
+    }
+
+    return volumeMounts;
+  }
+
+  private List<ContainerPort> getContainerPorts(Receiver receiver) {
+    List<ContainerPort> containerPorts = new ArrayList<>();
+    containerPorts.add(new ContainerPort(HTTP_PORT, null, null, "http", null));
+    return containerPorts;
+  }
+}
diff --git a/operator/src/main/java/com/google/gerrit/k8s/operator/receiver/ReceiverProbe.java b/operator/src/main/java/com/google/gerrit/k8s/operator/receiver/ReceiverProbe.java
new file mode 100644
index 0000000..8a0c11b
--- /dev/null
+++ b/operator/src/main/java/com/google/gerrit/k8s/operator/receiver/ReceiverProbe.java
@@ -0,0 +1,67 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.k8s.operator.receiver;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import io.fabric8.kubernetes.api.model.ExecAction;
+import io.fabric8.kubernetes.api.model.GRPCAction;
+import io.fabric8.kubernetes.api.model.HTTPGetAction;
+import io.fabric8.kubernetes.api.model.HTTPGetActionBuilder;
+import io.fabric8.kubernetes.api.model.IntOrString;
+import io.fabric8.kubernetes.api.model.Probe;
+
+public class ReceiverProbe extends Probe {
+  private static final long serialVersionUID = 1L;
+
+  private static final HTTPGetAction HTTP_GET_ACTION =
+      new HTTPGetActionBuilder()
+          .withPath("/")
+          .withPort(new IntOrString(ReceiverDeploymentDependentResource.HTTP_PORT))
+          .build();
+
+  @JsonIgnore private ExecAction exec;
+
+  @JsonIgnore private GRPCAction grpc;
+
+  @Override
+  public void setExec(ExecAction exec) {
+    super.setExec(null);
+  }
+
+  @Override
+  public void setGrpc(GRPCAction grpc) {
+    super.setGrpc(null);
+  }
+
+  @Override
+  public void setHttpGet(HTTPGetAction httpGet) {
+    super.setHttpGet(HTTP_GET_ACTION);
+  }
+
+  @Override
+  public ExecAction getExec() {
+    return null;
+  }
+
+  @Override
+  public GRPCAction getGrpc() {
+    return null;
+  }
+
+  @Override
+  public HTTPGetAction getHttpGet() {
+    return HTTP_GET_ACTION;
+  }
+}
diff --git a/operator/src/main/java/com/google/gerrit/k8s/operator/receiver/ReceiverReconciler.java b/operator/src/main/java/com/google/gerrit/k8s/operator/receiver/ReceiverReconciler.java
new file mode 100644
index 0000000..c1e741b
--- /dev/null
+++ b/operator/src/main/java/com/google/gerrit/k8s/operator/receiver/ReceiverReconciler.java
@@ -0,0 +1,159 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.k8s.operator.receiver;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.k8s.operator.cluster.GerritCluster;
+import io.fabric8.kubernetes.api.model.Secret;
+import io.fabric8.kubernetes.client.KubernetesClient;
+import io.javaoperatorsdk.operator.api.config.informer.InformerConfiguration;
+import io.javaoperatorsdk.operator.api.reconciler.Context;
+import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration;
+import io.javaoperatorsdk.operator.api.reconciler.EventSourceContext;
+import io.javaoperatorsdk.operator.api.reconciler.EventSourceInitializer;
+import io.javaoperatorsdk.operator.api.reconciler.Reconciler;
+import io.javaoperatorsdk.operator.api.reconciler.UpdateControl;
+import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent;
+import io.javaoperatorsdk.operator.processing.event.ResourceID;
+import io.javaoperatorsdk.operator.processing.event.source.EventSource;
+import io.javaoperatorsdk.operator.processing.event.source.SecondaryToPrimaryMapper;
+import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+@ControllerConfiguration(
+    dependents = {
+      @Dependent(name = "receiver-deployment", type = ReceiverDeploymentDependentResource.class),
+      @Dependent(
+          name = "receiver-service",
+          type = ReceiverServiceDependentResource.class,
+          dependsOn = {"receiver-deployment"})
+    })
+public class ReceiverReconciler implements Reconciler<Receiver>, EventSourceInitializer<Receiver> {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+  private static final String SECRET_EVENT_SOURCE_NAME = "secret-event-source";
+  private final KubernetesClient client;
+
+  public ReceiverReconciler(KubernetesClient client) {
+    this.client = client;
+  }
+
+  @Override
+  public Map<String, EventSource> prepareEventSources(EventSourceContext<Receiver> context) {
+    final SecondaryToPrimaryMapper<GerritCluster> gerritClusterMapper =
+        (GerritCluster cluster) ->
+            context
+                .getPrimaryCache()
+                .list(
+                    receiver ->
+                        receiver.getSpec().getCluster().equals(cluster.getMetadata().getName()))
+                .map(ResourceID::fromResource)
+                .collect(Collectors.toSet());
+
+    InformerEventSource<GerritCluster, Receiver> gerritClusterEventSource =
+        new InformerEventSource<>(
+            InformerConfiguration.from(GerritCluster.class, context)
+                .withSecondaryToPrimaryMapper(gerritClusterMapper)
+                .build(),
+            context);
+
+    final SecondaryToPrimaryMapper<Secret> secretMapper =
+        (Secret secret) ->
+            context
+                .getPrimaryCache()
+                .list(
+                    receiver ->
+                        receiver
+                            .getSpec()
+                            .getCredentialSecretRef()
+                            .equals(secret.getMetadata().getName()))
+                .map(ResourceID::fromResource)
+                .collect(Collectors.toSet());
+
+    InformerEventSource<Secret, Receiver> secretEventSource =
+        new InformerEventSource<>(
+            InformerConfiguration.from(Secret.class, context)
+                .withSecondaryToPrimaryMapper(secretMapper)
+                .build(),
+            context);
+
+    Map<String, EventSource> eventSources =
+        EventSourceInitializer.nameEventSources(gerritClusterEventSource);
+    eventSources.put(SECRET_EVENT_SOURCE_NAME, secretEventSource);
+    return eventSources;
+  }
+
+  @Override
+  public UpdateControl<Receiver> reconcile(Receiver receiver, Context<Receiver> context)
+      throws Exception {
+    if (receiver.getStatus() != null && isReceiverRestartRequired(receiver, context)) {
+      restartReceiverDeployment(receiver);
+    }
+
+    return UpdateControl.patchStatus(updateStatus(receiver, context));
+  }
+
+  void restartReceiverDeployment(Receiver receiver) {
+    logger.atInfo().log(
+        "Restarting Receiver %s due to configuration change.", receiver.getMetadata().getName());
+    client
+        .apps()
+        .deployments()
+        .inNamespace(receiver.getMetadata().getNamespace())
+        .withName(receiver.getMetadata().getName())
+        .rolling()
+        .restart();
+  }
+
+  private Receiver updateStatus(Receiver receiver, Context<Receiver> context) {
+    ReceiverStatus status = receiver.getStatus();
+    if (status == null) {
+      status = new ReceiverStatus();
+    }
+
+    Secret sec =
+        client
+            .secrets()
+            .inNamespace(receiver.getMetadata().getNamespace())
+            .withName(receiver.getSpec().getCredentialSecretRef())
+            .get();
+
+    if (sec != null) {
+      status.setAppliedCredentialSecretVersion(sec.getMetadata().getResourceVersion());
+    }
+
+    receiver.setStatus(status);
+    return receiver;
+  }
+
+  private boolean isReceiverRestartRequired(Receiver receiver, Context<Receiver> context) {
+    String secVersion =
+        client
+            .secrets()
+            .inNamespace(receiver.getMetadata().getNamespace())
+            .withName(receiver.getSpec().getCredentialSecretRef())
+            .get()
+            .getMetadata()
+            .getResourceVersion();
+    String appliedSecVersion = receiver.getStatus().getAppliedCredentialSecretVersion();
+    if (!secVersion.equals(appliedSecVersion)) {
+      logger.atFine().log(
+          "Looking up Secret: %s; Installed secret resource version: %s; Resource version known to the Receiver: %s",
+          receiver.getSpec().getCredentialSecretRef(), secVersion, appliedSecVersion);
+      return true;
+    }
+    return false;
+  }
+}
diff --git a/operator/src/main/java/com/google/gerrit/k8s/operator/receiver/ReceiverServiceConfig.java b/operator/src/main/java/com/google/gerrit/k8s/operator/receiver/ReceiverServiceConfig.java
new file mode 100644
index 0000000..4f4e10a
--- /dev/null
+++ b/operator/src/main/java/com/google/gerrit/k8s/operator/receiver/ReceiverServiceConfig.java
@@ -0,0 +1,40 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.k8s.operator.receiver;
+
+import java.io.Serializable;
+
+class ReceiverServiceConfig implements Serializable {
+  private static final long serialVersionUID = 1L;
+
+  String type = "NodePort";
+  int httpPort = 80;
+
+  public String getType() {
+    return type;
+  }
+
+  public void setType(String type) {
+    this.type = type;
+  }
+
+  public int getHttpPort() {
+    return httpPort;
+  }
+
+  public void setHttpPort(int httpPort) {
+    this.httpPort = httpPort;
+  }
+}
diff --git a/operator/src/main/java/com/google/gerrit/k8s/operator/receiver/ReceiverServiceDependentResource.java b/operator/src/main/java/com/google/gerrit/k8s/operator/receiver/ReceiverServiceDependentResource.java
new file mode 100644
index 0000000..1bce580
--- /dev/null
+++ b/operator/src/main/java/com/google/gerrit/k8s/operator/receiver/ReceiverServiceDependentResource.java
@@ -0,0 +1,85 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.k8s.operator.receiver;
+
+import static com.google.gerrit.k8s.operator.receiver.ReceiverDeploymentDependentResource.HTTP_PORT;
+
+import com.google.gerrit.k8s.operator.cluster.GerritCluster;
+import io.fabric8.kubernetes.api.model.Service;
+import io.fabric8.kubernetes.api.model.ServiceBuilder;
+import io.fabric8.kubernetes.api.model.ServicePort;
+import io.fabric8.kubernetes.api.model.ServicePortBuilder;
+import io.javaoperatorsdk.operator.api.reconciler.Context;
+import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource;
+import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+@KubernetesDependent
+public class ReceiverServiceDependentResource
+    extends CRUDKubernetesDependentResource<Service, Receiver> {
+  public static final String HTTP_PORT_NAME = "http";
+
+  public ReceiverServiceDependentResource() {
+    super(Service.class);
+  }
+
+  @Override
+  protected Service desired(Receiver receiver, Context<Receiver> context) {
+    GerritCluster gerritCluster =
+        client
+            .resources(GerritCluster.class)
+            .inNamespace(receiver.getMetadata().getNamespace())
+            .withName(receiver.getSpec().getCluster())
+            .get();
+    if (gerritCluster == null) {
+      throw new IllegalStateException("The Gerrit cluster could not be found.");
+    }
+
+    return new ServiceBuilder()
+        .withApiVersion("v1")
+        .withNewMetadata()
+        .withName(getName(receiver))
+        .withNamespace(receiver.getMetadata().getNamespace())
+        .withLabels(getLabels(gerritCluster))
+        .endMetadata()
+        .withNewSpec()
+        .withType(receiver.getSpec().getService().getType())
+        .withPorts(getServicePorts(receiver))
+        .withSelector(ReceiverDeploymentDependentResource.getLabels(gerritCluster, receiver))
+        .endSpec()
+        .build();
+  }
+
+  public static String getName(Receiver receiver) {
+    return receiver.getMetadata().getName();
+  }
+
+  public static Map<String, String> getLabels(GerritCluster gerritCluster) {
+    return gerritCluster.getLabels("receiver-service", ReceiverReconciler.class.getSimpleName());
+  }
+
+  private static List<ServicePort> getServicePorts(Receiver receiver) {
+    List<ServicePort> ports = new ArrayList<>();
+    ports.add(
+        new ServicePortBuilder()
+            .withName(HTTP_PORT_NAME)
+            .withPort(receiver.getSpec().getService().getHttpPort())
+            .withNewTargetPort(HTTP_PORT)
+            .build());
+    return ports;
+  }
+}
diff --git a/operator/src/main/java/com/google/gerrit/k8s/operator/receiver/ReceiverSpec.java b/operator/src/main/java/com/google/gerrit/k8s/operator/receiver/ReceiverSpec.java
new file mode 100644
index 0000000..5f6245d
--- /dev/null
+++ b/operator/src/main/java/com/google/gerrit/k8s/operator/receiver/ReceiverSpec.java
@@ -0,0 +1,151 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.k8s.operator.receiver;
+
+import com.google.gerrit.k8s.operator.cluster.GerritClusterMemberSpec;
+import io.fabric8.kubernetes.api.model.Affinity;
+import io.fabric8.kubernetes.api.model.IntOrString;
+import io.fabric8.kubernetes.api.model.ResourceRequirements;
+import io.fabric8.kubernetes.api.model.Toleration;
+import io.fabric8.kubernetes.api.model.TopologySpreadConstraint;
+import java.util.ArrayList;
+import java.util.List;
+
+public class ReceiverSpec implements GerritClusterMemberSpec {
+  private String cluster;
+
+  private List<Toleration> tolerations;
+  private Affinity affinity;
+  private List<TopologySpreadConstraint> topologySpreadConstraints = new ArrayList<>();
+  private String priorityClassName;
+
+  private int replicas = 1;
+  private IntOrString maxSurge = new IntOrString(1);
+  private IntOrString maxUnavailable = new IntOrString(1);
+
+  private ResourceRequirements resources;
+
+  private ReceiverProbe readinessProbe = new ReceiverProbe();
+  private ReceiverProbe livenessProbe = new ReceiverProbe();
+
+  private ReceiverServiceConfig service = new ReceiverServiceConfig();
+
+  private String credentialSecretRef;
+
+  public String getCluster() {
+    return cluster;
+  }
+
+  public void setCluster(String cluster) {
+    this.cluster = cluster;
+  }
+
+  public List<Toleration> getTolerations() {
+    return tolerations;
+  }
+
+  public void setTolerations(List<Toleration> tolerations) {
+    this.tolerations = tolerations;
+  }
+
+  public Affinity getAffinity() {
+    return affinity;
+  }
+
+  public void setAffinity(Affinity affinity) {
+    this.affinity = affinity;
+  }
+
+  public List<TopologySpreadConstraint> getTopologySpreadConstraints() {
+    return topologySpreadConstraints;
+  }
+
+  public void setTopologySpreadConstraints(
+      List<TopologySpreadConstraint> topologySpreadConstraints) {
+    this.topologySpreadConstraints = topologySpreadConstraints;
+  }
+
+  public String getPriorityClassName() {
+    return priorityClassName;
+  }
+
+  public void setPriorityClassName(String priorityClassName) {
+    this.priorityClassName = priorityClassName;
+  }
+
+  public int getReplicas() {
+    return replicas;
+  }
+
+  public void setReplicas(int replicas) {
+    this.replicas = replicas;
+  }
+
+  public IntOrString getMaxSurge() {
+    return maxSurge;
+  }
+
+  public void setMaxSurge(IntOrString maxSurge) {
+    this.maxSurge = maxSurge;
+  }
+
+  public IntOrString getMaxUnavailable() {
+    return maxUnavailable;
+  }
+
+  public void setMaxUnavailable(IntOrString maxUnavailable) {
+    this.maxUnavailable = maxUnavailable;
+  }
+
+  public ResourceRequirements getResources() {
+    return resources;
+  }
+
+  public void setResources(ResourceRequirements resources) {
+    this.resources = resources;
+  }
+
+  public ReceiverProbe getReadinessProbe() {
+    return readinessProbe;
+  }
+
+  public void setReadinessProbe(ReceiverProbe readinessProbe) {
+    this.readinessProbe = readinessProbe;
+  }
+
+  public ReceiverProbe getLivenessProbe() {
+    return livenessProbe;
+  }
+
+  public void setLivenessProbe(ReceiverProbe livenessProbe) {
+    this.livenessProbe = livenessProbe;
+  }
+
+  public ReceiverServiceConfig getService() {
+    return service;
+  }
+
+  public void setService(ReceiverServiceConfig service) {
+    this.service = service;
+  }
+
+  public String getCredentialSecretRef() {
+    return credentialSecretRef;
+  }
+
+  public void setCredentialSecretRef(String credentialSecretRef) {
+    this.credentialSecretRef = credentialSecretRef;
+  }
+}
diff --git a/operator/src/main/java/com/google/gerrit/k8s/operator/receiver/ReceiverStatus.java b/operator/src/main/java/com/google/gerrit/k8s/operator/receiver/ReceiverStatus.java
new file mode 100644
index 0000000..60c7ee2
--- /dev/null
+++ b/operator/src/main/java/com/google/gerrit/k8s/operator/receiver/ReceiverStatus.java
@@ -0,0 +1,36 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.k8s.operator.receiver;
+
+public class ReceiverStatus {
+  private boolean ready;
+  private String appliedCredentialSecretVersion = "";
+
+  public boolean isReady() {
+    return ready;
+  }
+
+  public void setReady(boolean ready) {
+    this.ready = ready;
+  }
+
+  public String getAppliedCredentialSecretVersion() {
+    return appliedCredentialSecretVersion;
+  }
+
+  public void setAppliedCredentialSecretVersion(String appliedCredentialSecretVersion) {
+    this.appliedCredentialSecretVersion = appliedCredentialSecretVersion;
+  }
+}
diff --git a/operator/src/test/java/com/google/gerrit/k8s/operator/gerrit/GerritE2E.java b/operator/src/test/java/com/google/gerrit/k8s/operator/gerrit/GerritE2E.java
index 670368a..c7b904d 100644
--- a/operator/src/test/java/com/google/gerrit/k8s/operator/gerrit/GerritE2E.java
+++ b/operator/src/test/java/com/google/gerrit/k8s/operator/gerrit/GerritE2E.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.k8s.operator.gerrit;
 
-import static com.google.gerrit.k8s.operator.test.TestGerritCluster.CLUSTER_NAME;
+import static com.google.gerrit.k8s.operator.cluster.GerritIngress.INGRESS_NAME;
 import static java.util.concurrent.TimeUnit.MINUTES;
 import static java.util.concurrent.TimeUnit.SECONDS;
 import static org.awaitility.Awaitility.await;
@@ -32,69 +32,23 @@
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.k8s.operator.gerrit.GerritSpec.GerritMode;
 import com.google.gerrit.k8s.operator.test.AbstractGerritOperatorE2ETest;
-import com.urswolfer.gerrit.client.rest.GerritAuthData;
-import com.urswolfer.gerrit.client.rest.GerritRestApiFactory;
+import com.google.gerrit.k8s.operator.test.TestGerrit;
 import io.fabric8.kubernetes.api.model.LoadBalancerIngress;
-import io.fabric8.kubernetes.api.model.ObjectMeta;
-import io.fabric8.kubernetes.api.model.ObjectMetaBuilder;
-import io.fabric8.kubernetes.api.model.Quantity;
-import io.fabric8.kubernetes.api.model.ResourceRequirementsBuilder;
-import io.fabric8.kubernetes.api.model.Secret;
-import io.fabric8.kubernetes.api.model.SecretBuilder;
 import io.fabric8.kubernetes.api.model.networking.v1.Ingress;
 import io.fabric8.kubernetes.api.model.networking.v1.IngressStatus;
-import java.util.Base64;
 import java.util.List;
-import java.util.Map;
-import java.util.Set;
 import org.junit.jupiter.api.Test;
 import org.mockito.Mockito;
 
 public class GerritE2E extends AbstractGerritOperatorE2ETest {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private static final String INGRESS_NAME = "gerrit-ingress";
-  private static final String INGRESS_DOMAIN = testProps.getIngressDomain();
-  private static final String SECURE_CONFIG_SECRET_NAME = "gerrit-secret";
-  private static final String DEFAULT_GERRIT_CONFIG =
-      "[gerrit]\n"
-          + "  serverId = gerrit-1\n"
-          + "[index]\n"
-          + "  type = LUCENE\n"
-          + "[auth]\n"
-          + "  type = LDAP\n"
-          + "[ldap]\n"
-          + "  server = ldap://openldap.openldap.svc.cluster.local:1389\n"
-          + "  accountBase = dc=example,dc=org\n"
-          + "  username = cn=admin,dc=example,dc=org\n"
-          + "[httpd]\n"
-          + "  listenUrl = proxy-https://*:8080/\n"
-          + "  requestLog = true\n"
-          + "  gracefulStopTimeout = 1m\n"
-          + "[sshd]\n"
-          + "  listenAddress = off\n"
-          + "[transfer]\n"
-          + "  timeout = 120 s\n"
-          + "[user]\n"
-          + "  name = Gerrit Code Review\n"
-          + "  email = gerrit@example.com\n"
-          + "  anonymousCoward = Unnamed User\n"
-          + "[cache]\n"
-          + "  directory = cache\n"
-          + "[container]\n"
-          + "  javaOptions = -Xmx4g";
-
   @Test
   void testPrimaryGerritIsCreated() throws Exception {
     gerritCluster.setIngressEnabled(true);
 
-    Secret secureConfig = createSecureConfig();
-    client.resource(secureConfig).createOrReplace();
-
-    Gerrit gerrit = createGerritCR(GerritMode.PRIMARY);
-    client.resource(gerrit).createOrReplace();
-
-    waitForGerritReadiness(gerrit);
+    TestGerrit testGerrit = new TestGerrit(client, testProps, operator.getNamespace());
+    testGerrit.deploy();
 
     logger.atInfo().log("Waiting max 2 minutes for the Ingress to have an external IP.");
     await()
@@ -117,13 +71,7 @@
               assertThat(lbIngresses.get(0).getIp(), is(notNullValue()));
             });
 
-    GerritApi gerritApi =
-        new GerritRestApiFactory()
-            .create(
-                new GerritAuthData.Basic(
-                    String.format(
-                        "http://%s.%s", ServiceDependentResource.getName(gerrit), INGRESS_DOMAIN)));
-
+    GerritApi gerritApi = testGerrit.getGerritApiClient();
     await()
         .atMost(2, MINUTES)
         .untilAsserted(
@@ -137,19 +85,15 @@
 
   @Test
   void testGerritReplicaIsCreated() throws Exception {
-    Secret secureConfig = createSecureConfig();
-    client.resource(secureConfig).createOrReplace();
-
-    Gerrit gerrit = createGerritCR(GerritMode.REPLICA);
-    client.resource(gerrit).createOrReplace();
-
-    waitForGerritReadiness(gerrit);
+    TestGerrit testGerrit =
+        new TestGerrit(client, testProps, operator.getNamespace(), GerritMode.REPLICA);
+    testGerrit.deploy();
 
     assertTrue(
         client
             .pods()
             .inNamespace(operator.getNamespace())
-            .withName(gerrit.getMetadata().getName() + "-0")
+            .withName(TestGerrit.NAME + "-0")
             .inContainer("gerrit")
             .getLog()
             .contains("Gerrit Code Review [replica]"));
@@ -157,33 +101,15 @@
 
   @Test
   void testRestartHandlingOnConfigChange() {
-    Secret secureConfig = createSecureConfig();
-    client.resource(secureConfig).createOrReplace();
-
-    Gerrit gerrit = createGerritCR(GerritMode.PRIMARY);
-    client.resource(gerrit).createOrReplace();
-
-    logger.atInfo().log("Waiting max 2 minutes for the Gerrit StatefulSet to be ready.");
-    await()
-        .atMost(2, MINUTES)
-        .untilAsserted(
-            () -> {
-              assertTrue(
-                  client
-                      .apps()
-                      .statefulSets()
-                      .inNamespace(operator.getNamespace())
-                      .withName(gerrit.getMetadata().getName())
-                      .isReady());
-            });
+    TestGerrit testGerrit = new TestGerrit(client, testProps, operator.getNamespace());
+    testGerrit.deploy();
 
     GerritServiceConfig svcConfig = new GerritServiceConfig();
     int changedPort = 8081;
     svcConfig.setHttpPort(changedPort);
-    GerritSpec gerritSpec = gerrit.getSpec();
+    GerritSpec gerritSpec = testGerrit.getSpec();
     gerritSpec.setService(svcConfig);
-    gerrit.setSpec(gerritSpec);
-    client.resource(gerrit).createOrReplace();
+    testGerrit.setSpec(gerritSpec);
 
     await()
         .atMost(30, SECONDS)
@@ -193,7 +119,7 @@
                   client
                       .services()
                       .inNamespace(operator.getNamespace())
-                      .withName(ServiceDependentResource.getName(gerrit))
+                      .withName(TestGerrit.NAME)
                       .get()
                       .getSpec()
                       .getPorts()
@@ -202,11 +128,7 @@
             });
     Mockito.verify(gerritReconciler, times(1)).restartGerritStatefulSet(any());
 
-    String changedConfig =
-        DEFAULT_GERRIT_CONFIG.replace("proxy-https://*:8080/", "proxy-https://*:8081/");
-    gerritSpec.setConfigFiles(Map.of("gerrit.config", changedConfig));
-    gerrit.setSpec(gerritSpec);
-    client.resource(gerrit).createOrReplace();
+    testGerrit.modifyGerritConfig("test", "test", "test");
 
     await()
         .atMost(2, MINUTES)
@@ -215,11 +137,7 @@
               Mockito.verify(gerritReconciler, times(2)).restartGerritStatefulSet(any());
             });
 
-    secureConfig.setData(
-        Map.of(
-            "secure.config",
-            Base64.getEncoder().encodeToString("[section]\nkey = value_new".getBytes())));
-    client.resource(secureConfig).createOrReplace();
+    testGerrit.modifySecureConfig("test", "test", "test");
 
     await()
         .atMost(2, MINUTES)
@@ -228,108 +146,4 @@
               Mockito.verify(gerritReconciler, times(3)).restartGerritStatefulSet(any());
             });
   }
-
-  private Gerrit createGerritCR(GerritMode mode) {
-    Gerrit gerrit = new Gerrit();
-    ObjectMeta gerritMeta =
-        new ObjectMetaBuilder().withName("gerrit").withNamespace(operator.getNamespace()).build();
-    gerrit.setMetadata(gerritMeta);
-    GerritSpec gerritSpec = new GerritSpec();
-    gerritSpec.setCluster(CLUSTER_NAME);
-    GerritSite site = new GerritSite();
-    site.setSize(new Quantity("1Gi"));
-    gerritSpec.setMode(mode);
-    gerritSpec.setSite(site);
-    gerritSpec.setResources(
-        new ResourceRequirementsBuilder()
-            .withRequests(Map.of("cpu", new Quantity("1"), "memory", new Quantity("5Gi")))
-            .build());
-    gerritSpec.setConfigFiles(Map.of("gerrit.config", DEFAULT_GERRIT_CONFIG));
-    gerritSpec.setSecrets(Set.of(SECURE_CONFIG_SECRET_NAME));
-
-    gerrit.setSpec(gerritSpec);
-    return gerrit;
-  }
-
-  private Secret createSecureConfig() {
-    return new SecretBuilder()
-        .withNewMetadata()
-        .withNamespace(operator.getNamespace())
-        .withName(SECURE_CONFIG_SECRET_NAME)
-        .endMetadata()
-        .withData(
-            Map.of(
-                "secure.config",
-                Base64.getEncoder()
-                    .encodeToString(
-                        String.format("[ldap]\npassword = %s", testProps.getLdapAdminPwd())
-                            .getBytes())))
-        .build();
-  }
-
-  private void waitForGerritReadiness(Gerrit gerrit) {
-    logger.atInfo().log("Waiting max 1 minutes for the configmaps to be created.");
-    await()
-        .atMost(1, MINUTES)
-        .untilAsserted(
-            () -> {
-              assertThat(
-                  client
-                      .configMaps()
-                      .inNamespace(operator.getNamespace())
-                      .withName(GerritConfigMapDependentResource.getName(gerrit))
-                      .get(),
-                  is(notNullValue()));
-              assertThat(
-                  client
-                      .configMaps()
-                      .inNamespace(operator.getNamespace())
-                      .withName(GerritInitConfigMapDependentResource.getName(gerrit))
-                      .get(),
-                  is(notNullValue()));
-            });
-
-    logger.atInfo().log("Waiting max 1 minutes for the Gerrit StatefulSet to be created.");
-    await()
-        .atMost(1, MINUTES)
-        .untilAsserted(
-            () -> {
-              assertThat(
-                  client
-                      .apps()
-                      .statefulSets()
-                      .inNamespace(operator.getNamespace())
-                      .withName(gerrit.getMetadata().getName())
-                      .get(),
-                  is(notNullValue()));
-            });
-
-    logger.atInfo().log("Waiting max 1 minutes for the Gerrit Service to be created.");
-    await()
-        .atMost(1, MINUTES)
-        .untilAsserted(
-            () -> {
-              assertThat(
-                  client
-                      .services()
-                      .inNamespace(operator.getNamespace())
-                      .withName(ServiceDependentResource.getName(gerrit))
-                      .get(),
-                  is(notNullValue()));
-            });
-
-    logger.atInfo().log("Waiting max 2 minutes for the Gerrit StatefulSet to be ready.");
-    await()
-        .atMost(2, MINUTES)
-        .untilAsserted(
-            () -> {
-              assertTrue(
-                  client
-                      .apps()
-                      .statefulSets()
-                      .inNamespace(operator.getNamespace())
-                      .withName(gerrit.getMetadata().getName())
-                      .isReady());
-            });
-  }
 }
diff --git a/operator/src/test/java/com/google/gerrit/k8s/operator/receiver/ReceiverE2E.java b/operator/src/test/java/com/google/gerrit/k8s/operator/receiver/ReceiverE2E.java
new file mode 100644
index 0000000..1ddd83b
--- /dev/null
+++ b/operator/src/test/java/com/google/gerrit/k8s/operator/receiver/ReceiverE2E.java
@@ -0,0 +1,223 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.k8s.operator.receiver;
+
+import static com.google.gerrit.k8s.operator.cluster.GerritIngress.INGRESS_NAME;
+import static java.util.concurrent.TimeUnit.MINUTES;
+import static org.awaitility.Awaitility.await;
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.hasSize;
+import static org.hamcrest.Matchers.notNullValue;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.google.gerrit.k8s.operator.gerrit.GerritSpec.GerritMode;
+import com.google.gerrit.k8s.operator.test.AbstractGerritOperatorE2ETest;
+import com.google.gerrit.k8s.operator.test.TestGerrit;
+import com.google.gerrit.k8s.operator.test.TestGerritCluster;
+import io.fabric8.kubernetes.api.model.LoadBalancerIngress;
+import io.fabric8.kubernetes.api.model.ObjectMeta;
+import io.fabric8.kubernetes.api.model.ObjectMetaBuilder;
+import io.fabric8.kubernetes.api.model.Secret;
+import io.fabric8.kubernetes.api.model.SecretBuilder;
+import io.fabric8.kubernetes.api.model.networking.v1.Ingress;
+import io.fabric8.kubernetes.api.model.networking.v1.IngressStatus;
+import java.io.File;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
+import java.util.Base64;
+import java.util.List;
+import java.util.Map;
+import org.apache.commons.codec.digest.Md5Crypt;
+import org.apache.commons.lang3.RandomStringUtils;
+import org.apache.http.client.utils.URIBuilder;
+import org.eclipse.jgit.api.Git;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.transport.CredentialsProvider;
+import org.eclipse.jgit.transport.RefSpec;
+import org.eclipse.jgit.transport.URIish;
+import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInstance;
+import org.junit.jupiter.api.TestInstance.Lifecycle;
+import org.junit.jupiter.api.io.TempDir;
+
+@TestInstance(Lifecycle.PER_CLASS)
+public class ReceiverE2E extends AbstractGerritOperatorE2ETest {
+  private static final String CREDENTIALS_SECRET_NAME = "receiver-secret";
+  private static final String USER = "git";
+  private static final String PASSWORD = RandomStringUtils.randomAlphanumeric(32);
+
+  private Receiver receiver;
+  private TestGerrit gerritReplica;
+
+  @BeforeAll
+  public void setupReceiver() {
+    receiver = new Receiver();
+    ObjectMeta receiverMeta = new ObjectMetaBuilder().withName("receiver").build();
+    receiver.setMetadata(receiverMeta);
+    ReceiverSpec receiverSpec = new ReceiverSpec();
+    receiverSpec.setCluster(TestGerritCluster.CLUSTER_NAME);
+    receiverSpec.setReplicas(2);
+    receiverSpec.setCredentialSecretRef(CREDENTIALS_SECRET_NAME);
+    receiver.setSpec(receiverSpec);
+  }
+
+  @BeforeEach
+  public void setupComponents() {
+    gerritCluster.setIngressEnabled(true);
+    createCredentialsSecret();
+    client.resource(receiver).inNamespace(operator.getNamespace()).createOrReplace();
+    awaitReceiverReadiness();
+
+    gerritReplica = new TestGerrit(client, testProps, operator.getNamespace(), GerritMode.REPLICA);
+    gerritReplica.deploy();
+  }
+
+  @Test
+  public void testProjectLifecycle(@TempDir Path tempDir) throws Exception {
+    assertThat(sendReceiverApiRequest("GET", "/new/testLegacy.git"), is(equalTo(201)));
+    assertThat(sendReceiverApiRequest("PUT", "/a/projects/test.git"), is(equalTo(201)));
+    CredentialsProvider gerritCredentials =
+        new UsernamePasswordCredentialsProvider(
+            testProps.getGerritUser(), testProps.getGerritPwd());
+    Git git =
+        Git.cloneRepository()
+            .setURI(getGerritUrl("/a/test.git").toString())
+            .setCredentialsProvider(gerritCredentials)
+            .setDirectory(tempDir.toFile())
+            .call();
+    new File("test.txt").createNewFile();
+    git.add().addFilepattern(".").call();
+    RevCommit commit = git.commit().setMessage("test commit").call();
+    git.remoteAdd()
+        .setName("receiver")
+        .setUri(new URIish(getReceiverUrl("/git/test.git").toString()))
+        .call();
+    git.push()
+        .setCredentialsProvider(new UsernamePasswordCredentialsProvider(USER, PASSWORD))
+        .setRemote("receiver")
+        .setRefSpecs(new RefSpec("refs/heads/master"))
+        .call();
+    assertTrue(
+        git.lsRemote().setCredentialsProvider(gerritCredentials).setRemote("origin").call().stream()
+            .anyMatch(ref -> ref.getObjectId().equals(commit.getId())));
+    assertThat(sendReceiverApiRequest("DELETE", "/a/projects/test.git"), is(equalTo(204)));
+  }
+
+  private void awaitReceiverReadiness() {
+    await()
+        .atMost(1, MINUTES)
+        .untilAsserted(
+            () -> {
+              assertThat(
+                  client
+                      .services()
+                      .inNamespace(operator.getNamespace())
+                      .withName(ReceiverServiceDependentResource.getName(receiver))
+                      .get(),
+                  is(notNullValue()));
+            });
+
+    await()
+        .atMost(2, MINUTES)
+        .untilAsserted(
+            () -> {
+              assertTrue(
+                  client
+                      .apps()
+                      .deployments()
+                      .inNamespace(operator.getNamespace())
+                      .withName(receiver.getMetadata().getName())
+                      .isReady());
+            });
+
+    await()
+        .atMost(2, MINUTES)
+        .untilAsserted(
+            () -> {
+              Ingress ingress =
+                  client
+                      .network()
+                      .v1()
+                      .ingresses()
+                      .inNamespace(operator.getNamespace())
+                      .withName(INGRESS_NAME)
+                      .get();
+              assertThat(ingress, is(notNullValue()));
+              IngressStatus status = ingress.getStatus();
+              assertThat(status, is(notNullValue()));
+              List<LoadBalancerIngress> lbIngresses = status.getLoadBalancer().getIngress();
+              assertThat(lbIngresses, hasSize(1));
+              assertThat(lbIngresses.get(0).getIp(), is(notNullValue()));
+            });
+  }
+
+  private int sendReceiverApiRequest(String method, String path) throws Exception {
+    URL url = getReceiverUrl(path);
+
+    HttpURLConnection con = (HttpURLConnection) url.openConnection();
+    try {
+      con.setRequestMethod(method);
+      String encodedAuth =
+          Base64.getEncoder()
+              .encodeToString(
+                  String.format("%s:%s", USER, PASSWORD).getBytes(StandardCharsets.UTF_8));
+      con.setRequestProperty("Authorization", "Basic " + encodedAuth);
+      return con.getResponseCode();
+    } finally {
+      con.disconnect();
+    }
+  }
+
+  private URL getReceiverUrl(String path) throws Exception {
+    return new URIBuilder()
+        .setScheme("https")
+        .setHost(
+            String.format("%s.%s", receiver.getMetadata().getName(), testProps.getIngressDomain()))
+        .setPath(path)
+        .build()
+        .toURL();
+  }
+
+  private URL getGerritUrl(String path) throws Exception {
+    return new URIBuilder()
+        .setScheme("https")
+        .setHost(String.format("%s.%s", TestGerrit.NAME, testProps.getIngressDomain()))
+        .setPath(path)
+        .build()
+        .toURL();
+  }
+
+  private void createCredentialsSecret() {
+    String enPasswd = Md5Crypt.md5Crypt(PASSWORD.getBytes());
+    String htpasswdContent = USER + ":" + enPasswd;
+    Secret sec =
+        new SecretBuilder()
+            .withNewMetadata()
+            .withNamespace(operator.getNamespace())
+            .withName(CREDENTIALS_SECRET_NAME)
+            .endMetadata()
+            .withData(
+                Map.of(".htpasswd", Base64.getEncoder().encodeToString(htpasswdContent.getBytes())))
+            .build();
+    client.resource(sec).createOrReplace();
+  }
+}
diff --git a/operator/src/test/java/com/google/gerrit/k8s/operator/test/AbstractGerritOperatorE2ETest.java b/operator/src/test/java/com/google/gerrit/k8s/operator/test/AbstractGerritOperatorE2ETest.java
index c8729a3..6ed616c 100644
--- a/operator/src/test/java/com/google/gerrit/k8s/operator/test/AbstractGerritOperatorE2ETest.java
+++ b/operator/src/test/java/com/google/gerrit/k8s/operator/test/AbstractGerritOperatorE2ETest.java
@@ -21,6 +21,8 @@
 import com.google.gerrit.k8s.operator.gerrit.GerritReconciler;
 import com.google.gerrit.k8s.operator.gitgc.GitGarbageCollection;
 import com.google.gerrit.k8s.operator.gitgc.GitGarbageCollectionReconciler;
+import com.google.gerrit.k8s.operator.receiver.Receiver;
+import com.google.gerrit.k8s.operator.receiver.ReceiverReconciler;
 import io.fabric8.kubernetes.api.model.Secret;
 import io.fabric8.kubernetes.api.model.SecretBuilder;
 import io.fabric8.kubernetes.client.Config;
@@ -53,6 +55,7 @@
           .withReconciler(new GerritClusterReconciler(client))
           .withReconciler(gerritReconciler)
           .withReconciler(new GitGarbageCollectionReconciler(client))
+          .withReconciler(new ReceiverReconciler(client))
           .build();
 
   @BeforeEach
@@ -66,6 +69,7 @@
   @AfterEach
   void cleanup() {
     client.resources(Gerrit.class).inNamespace(operator.getNamespace()).delete();
+    client.resources(Receiver.class).inNamespace(operator.getNamespace()).delete();
     client.resources(GerritCluster.class).inNamespace(operator.getNamespace()).delete();
     client.resources(GitGarbageCollection.class).inNamespace(operator.getNamespace()).delete();
   }
diff --git a/operator/src/test/java/com/google/gerrit/k8s/operator/test/TestGerrit.java b/operator/src/test/java/com/google/gerrit/k8s/operator/test/TestGerrit.java
new file mode 100644
index 0000000..5c014f6
--- /dev/null
+++ b/operator/src/test/java/com/google/gerrit/k8s/operator/test/TestGerrit.java
@@ -0,0 +1,255 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.k8s.operator.test;
+
+import static com.google.gerrit.k8s.operator.test.TestGerritCluster.CLUSTER_NAME;
+import static java.util.concurrent.TimeUnit.MINUTES;
+import static org.awaitility.Awaitility.await;
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.notNullValue;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.extensions.api.GerritApi;
+import com.google.gerrit.k8s.operator.gerrit.Gerrit;
+import com.google.gerrit.k8s.operator.gerrit.GerritConfigMapDependentResource;
+import com.google.gerrit.k8s.operator.gerrit.GerritInitConfigMapDependentResource;
+import com.google.gerrit.k8s.operator.gerrit.GerritSite;
+import com.google.gerrit.k8s.operator.gerrit.GerritSpec;
+import com.google.gerrit.k8s.operator.gerrit.GerritSpec.GerritMode;
+import com.google.gerrit.k8s.operator.gerrit.ServiceDependentResource;
+import com.urswolfer.gerrit.client.rest.GerritAuthData;
+import com.urswolfer.gerrit.client.rest.GerritRestApiFactory;
+import io.fabric8.kubernetes.api.model.ObjectMeta;
+import io.fabric8.kubernetes.api.model.ObjectMetaBuilder;
+import io.fabric8.kubernetes.api.model.Quantity;
+import io.fabric8.kubernetes.api.model.ResourceRequirementsBuilder;
+import io.fabric8.kubernetes.api.model.Secret;
+import io.fabric8.kubernetes.api.model.SecretBuilder;
+import io.fabric8.kubernetes.client.KubernetesClient;
+import java.util.Base64;
+import java.util.Map;
+import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+
+public class TestGerrit {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+  public static final String NAME = "gerrit";
+  private static final String SECURE_CONFIG_SECRET_NAME = "gerrit-secret";
+  private static final String DEFAULT_GERRIT_CONFIG =
+      "[gerrit]\n"
+          + "  serverId = gerrit-1\n"
+          + "[index]\n"
+          + "  type = LUCENE\n"
+          + "[auth]\n"
+          + "  type = LDAP\n"
+          + "[ldap]\n"
+          + "  server = ldap://openldap.openldap.svc.cluster.local:1389\n"
+          + "  accountBase = dc=example,dc=org\n"
+          + "  username = cn=admin,dc=example,dc=org\n"
+          + "[httpd]\n"
+          + "  listenUrl = proxy-https://*:8080/\n"
+          + "  requestLog = true\n"
+          + "  gracefulStopTimeout = 1m\n"
+          + "[sshd]\n"
+          + "  listenAddress = off\n"
+          + "[transfer]\n"
+          + "  timeout = 120 s\n"
+          + "[user]\n"
+          + "  name = Gerrit Code Review\n"
+          + "  email = gerrit@example.com\n"
+          + "  anonymousCoward = Unnamed User\n"
+          + "[cache]\n"
+          + "  directory = cache\n"
+          + "[container]\n"
+          + "  javaOptions = -Xmx4g";
+
+  private final KubernetesClient client;
+  private final String namespace;
+  private final GerritMode mode;
+  private final String ingress_domain;
+
+  private Secret secureConfigSecret;
+  private Gerrit gerrit = new Gerrit();
+  private Config config = defaultConfig();
+  private Config secureConfig = new Config();
+
+  public TestGerrit(
+      KubernetesClient client, TestProperties testProps, String namespace, GerritMode mode) {
+    this.client = client;
+    this.namespace = namespace;
+    this.mode = mode;
+    this.ingress_domain = testProps.getIngressDomain();
+    this.secureConfig.setString("ldap", null, "password", testProps.getLdapAdminPwd());
+  }
+
+  public TestGerrit(KubernetesClient client, TestProperties testProps, String namespace) {
+    this(client, testProps, namespace, GerritMode.PRIMARY);
+  }
+
+  public void build() {
+    createGerritCR();
+    createSecureConfig();
+  }
+
+  public void deploy() {
+    build();
+    client.resource(secureConfigSecret).inNamespace(namespace).createOrReplace();
+    client.resource(gerrit).inNamespace(namespace).createOrReplace();
+    waitForGerritReadiness();
+  }
+
+  public GerritApi getGerritApiClient() {
+    return new GerritRestApiFactory()
+        .create(
+            new GerritAuthData.Basic(
+                String.format(
+                    "http://%s.%s", ServiceDependentResource.getName(gerrit), ingress_domain)));
+  }
+
+  public void modifyGerritConfig(String section, String key, String value) {
+    config.setString(section, null, key, value);
+    deploy();
+  }
+
+  public void modifySecureConfig(String section, String key, String value) {
+    secureConfig.setString(section, null, key, value);
+    createSecureConfig();
+    client.resource(secureConfigSecret).inNamespace(namespace).createOrReplace();
+  }
+
+  public GerritSpec getSpec() {
+    return gerrit.getSpec();
+  }
+
+  public void setSpec(GerritSpec spec) {
+    gerrit.setSpec(spec);
+    deploy();
+  }
+
+  private static Config defaultConfig() {
+    Config cfg = new Config();
+    try {
+      cfg.fromText(DEFAULT_GERRIT_CONFIG);
+    } catch (ConfigInvalidException e) {
+      throw new IllegalStateException("Illegal default test configuration.");
+    }
+    return cfg;
+  }
+
+  private void createGerritCR() {
+    ObjectMeta gerritMeta = new ObjectMetaBuilder().withName(NAME).withNamespace(namespace).build();
+    gerrit.setMetadata(gerritMeta);
+    GerritSpec gerritSpec = gerrit.getSpec();
+    if (gerritSpec == null) {
+      gerritSpec = new GerritSpec();
+      GerritSite site = new GerritSite();
+      site.setSize(new Quantity("1Gi"));
+      gerritSpec.setSite(site);
+      gerritSpec.setResources(
+          new ResourceRequirementsBuilder()
+              .withRequests(Map.of("cpu", new Quantity("1"), "memory", new Quantity("5Gi")))
+              .build());
+    }
+    gerritSpec.setCluster(CLUSTER_NAME);
+    gerritSpec.setMode(mode);
+    gerritSpec.setConfigFiles(Map.of("gerrit.config", config.toText()));
+    gerritSpec.setSecrets(Set.of(SECURE_CONFIG_SECRET_NAME));
+
+    gerrit.setSpec(gerritSpec);
+  }
+
+  private void createSecureConfig() {
+    secureConfigSecret =
+        new SecretBuilder()
+            .withNewMetadata()
+            .withNamespace(namespace)
+            .withName(SECURE_CONFIG_SECRET_NAME)
+            .endMetadata()
+            .withData(
+                Map.of(
+                    "secure.config",
+                    Base64.getEncoder().encodeToString(secureConfig.toText().getBytes())))
+            .build();
+  }
+
+  private void waitForGerritReadiness() {
+    logger.atInfo().log("Waiting max 1 minutes for the configmaps to be created.");
+    await()
+        .atMost(1, MINUTES)
+        .untilAsserted(
+            () -> {
+              assertThat(
+                  client
+                      .configMaps()
+                      .inNamespace(namespace)
+                      .withName(GerritConfigMapDependentResource.getName(gerrit))
+                      .get(),
+                  is(notNullValue()));
+              assertThat(
+                  client
+                      .configMaps()
+                      .inNamespace(namespace)
+                      .withName(GerritInitConfigMapDependentResource.getName(gerrit))
+                      .get(),
+                  is(notNullValue()));
+            });
+
+    logger.atInfo().log("Waiting max 1 minutes for the Gerrit StatefulSet to be created.");
+    await()
+        .atMost(1, MINUTES)
+        .untilAsserted(
+            () -> {
+              assertThat(
+                  client
+                      .apps()
+                      .statefulSets()
+                      .inNamespace(namespace)
+                      .withName(gerrit.getMetadata().getName())
+                      .get(),
+                  is(notNullValue()));
+            });
+
+    logger.atInfo().log("Waiting max 1 minutes for the Gerrit Service to be created.");
+    await()
+        .atMost(1, MINUTES)
+        .untilAsserted(
+            () -> {
+              assertThat(
+                  client
+                      .services()
+                      .inNamespace(namespace)
+                      .withName(ServiceDependentResource.getName(gerrit))
+                      .get(),
+                  is(notNullValue()));
+            });
+
+    logger.atInfo().log("Waiting max 2 minutes for the Gerrit StatefulSet to be ready.");
+    await()
+        .atMost(2, MINUTES)
+        .untilAsserted(
+            () -> {
+              assertTrue(
+                  client
+                      .apps()
+                      .statefulSets()
+                      .inNamespace(namespace)
+                      .withName(gerrit.getMetadata().getName())
+                      .isReady());
+            });
+  }
+}
diff --git a/operator/src/test/java/com/google/gerrit/k8s/operator/test/TestProperties.java b/operator/src/test/java/com/google/gerrit/k8s/operator/test/TestProperties.java
index 4581590..e3b58f2 100644
--- a/operator/src/test/java/com/google/gerrit/k8s/operator/test/TestProperties.java
+++ b/operator/src/test/java/com/google/gerrit/k8s/operator/test/TestProperties.java
@@ -63,4 +63,12 @@
   public String getLdapAdminPwd() {
     return props.getProperty("ldapAdminPwd", "");
   }
+
+  public String getGerritUser() {
+    return props.getProperty("gerritUser", "");
+  }
+
+  public String getGerritPwd() {
+    return props.getProperty("gerritPwd", "");
+  }
 }
diff --git a/operator/test.properties b/operator/test.properties
index 9e48c77..f8f65f7 100644
--- a/operator/test.properties
+++ b/operator/test.properties
@@ -10,3 +10,8 @@
 
 # Ingress
 ingressDomain=
+
+# LDAP
+ldapAdminPwd=
+gerritUser=
+gerritPwd=