Add CustomResource for primary Gerrit instance

With this resource users can create a Gerrit custom resource
that will be used by the operator to create the ConfigMaps,
StatefulSet and Service for a primary Gerrit.

Note that secrets will have to be provided manually and
referenced via name. The reason secrets won't be part of
the CustomResource, since they would be visible as plain
text for everybody having access to the CustomResource.

Change-Id: I9b294f317abeb7968f279773caf20c35fadc9468
diff --git a/operator/README.md b/operator/README.md
index 09fbc73..84108d9 100644
--- a/operator/README.md
+++ b/operator/README.md
@@ -49,6 +49,17 @@
 kubectl apply -f k8s/cluster.sample.yaml
 ```
 
+### Gerrit
+
+An example of a Gerrit-CustomResource can be found at `k8s/gerrit.sample.yaml`.
+To install it into the cluster run:
+
+```sh
+kubectl apply -f k8s/gerrit.sample.yaml
+```
+
+The operator will create all resources to run a primary Gerrit.
+
 ### GitGarbageCollection
 
 An example of a GitGc-CustomResource can be found at `k8s/gitgc.sample.yaml`.
@@ -149,14 +160,185 @@
     size: 1Gi
 
     ## Name of a specific persistent volume to claim (optional)
-    volumeName: logs
+    volumeName: ""
 
     ## Selector (https://kubernetes.io/docs/concepts/storage/persistent-volumes/#selector)
     ## to select a specific persistent volume (optional)
-    selector:
-      matchLabels:
-        volume-type: ssd
-        aws-availability-zone: us-east-1
+    selector: {}
+      # matchLabels:
+      #   volume-type: ssd
+      #   aws-availability-zone: us-east-1
+```
+
+### Gerrit
+
+```yaml
+apiVersion: "gerritoperator.google.com/v1alpha1"
+kind: Gerrit
+metadata:
+  name: gerrit
+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 at which to start updating pods. Pods with a lower ordinal will not be updated. (default: 0)
+  updatePartition: 0
+
+  ## Resource requests/limits of the gerrit container
+  ## (https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/)
+  ## (optional)
+  resources: {}
+    # requests:
+    #   cpu: 1
+    #   memory: 5Gi
+    # limits:
+    #   cpu: 1
+    #   memory: 6Gi
+
+  ## Startup 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)
+  startupProbe: {}
+    # initialDelaySeconds: 0
+    # periodSeconds: 10
+    # timeoutSeconds: 1
+    # successThreshold: 1
+    # failureThreshold: 3
+
+  ## 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
+
+  ## Seconds the pod is allowed to shutdown until it is forcefully killed (default: 30)
+  gracefulStopTimeout: 30
+
+  ## 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
+
+    ## Port used for SSH requests (optional; if unset, SSH access is disabled)
+    sshPort: null
+
+  ## Configuration concerning the Gerrit site
+  site:
+    ## Size of the volume used to persist not otherwise persisted site components
+    ## (e.g. git repositories are persisted in a dedicated volume) (mandatory)
+    size: 1Gi
+
+  ## List of Gerrit plugins to install. These plugins can either be packaged in
+  ## the Gerrit war-file or they will be downloaded. (optional)
+  plugins: []
+  ## Installs a packaged plugin
+  # - name: delete-project
+
+  ## Downloads and installs a plugin
+  # - name: javamelody
+  #   url: https://gerrit-ci.gerritforge.com/view/Plugins-stable-3.6/job/plugin-javamelody-bazel-master-stable-3.6/lastSuccessfulBuild/artifact/bazel-bin/plugins/javamelody/javamelody.jar
+  #   sha1: 40ffcd00263171e373a24eb6a311791b2924707c
+
+  ## If the `installAsLibrary` option is set to `true` the plugin's jar-file will
+  ## be symlinked to the lib directory and thus installed as a library as well.
+  # - name: saml
+  #   url: https://gerrit-ci.gerritforge.com/view/Plugins-stable-3.6/job/plugin-saml-bazel-master-stable-3.6/lastSuccessfulBuild/artifact/bazel-bin/plugins/saml/saml.jar
+  #   sha1: 6dfe8292d46b179638586e6acf671206f4e0a88b
+  #   installAsLibrary: true
+
+  ## Configuration files for Gerrit that will be mounted into the Gerrit site's
+  ## etc-directory (gerrit.config is mandatory)
+  configFiles:
+    gerrit.config: |-
+        [gerrit]
+          basePath = git
+          serverId = gerrit-1
+          canonicalWebUrl = http://example.com/
+          disableReverseDnsLookup = true
+        [index]
+          type = LUCENE
+          onlineUpgrade = false
+        [auth]
+          type = DEVELOPMENT_BECOME_ANY_ACCOUNT
+        [httpd]
+          listenUrl = proxy-http://*:8080/
+          requestLog = true
+          gracefulStopTimeout = 1m
+        [sshd]
+          listenAddress = off
+        [transfer]
+          timeout = 120 s
+        [user]
+          name = Gerrit Code Review
+          email = gerrit@example.com
+          anonymousCoward = Unnamed User
+        [cache]
+          directory = cache
+        [container]
+          user = gerrit
+          javaHome = /usr/lib/jvm/java-11-openjdk
+          javaOptions = -Djavax.net.ssl.trustStore=/var/gerrit/etc/keystore
+          javaOptions = -Xms200m
+          javaOptions = -Xmx4g
+
+  ## Names of secrets containing configuration files, e.g. secure.config, that
+  ## will be mounted into the Gerrit site's etc-directory (optional)
+  secrets: []
+  # - gerrit-secure-config
 ```
 
 ### GitGarbageCollection
diff --git a/operator/k8s/gerrit.sample.yaml b/operator/k8s/gerrit.sample.yaml
new file mode 100644
index 0000000..058242e
--- /dev/null
+++ b/operator/k8s/gerrit.sample.yaml
@@ -0,0 +1,39 @@
+apiVersion: "gerritoperator.google.com/v1alpha1"
+kind: Gerrit
+metadata:
+  name: gerrit
+spec:
+  cluster: gerrit
+  site:
+    size: 5Gi
+  plugins: []
+  configFiles:
+    gerrit.config: |-
+      [auth]
+        type = DEVELOPMENT_BECOME_ANY_ACCOUNT
+      [cache]
+        directory = cache
+      [container]
+        javaOptions = -Xms200m
+        javaOptions = -Xmx4g
+      [gerrit]
+        serverId = gerrit-1
+      [transfer]
+        timeout = 120 s
+      [user]
+        name = Gerrit Code Review
+        email = gerrit@example.com
+        anonymousCoward = Unnamed User
+  secrets:
+  - gerrit-secure-config
+---
+apiVersion: v1
+kind: Secret
+metadata:
+  name:  gerrit-secure-config
+  labels:
+    app: gerrit
+data:
+  keystore: ""
+  secure.config: ""
+type: Opaque
diff --git a/operator/k8s/operator.yaml b/operator/k8s/operator.yaml
index f0127f6..4a08f00 100644
--- a/operator/k8s/operator.yaml
+++ b/operator/k8s/operator.yaml
@@ -70,9 +70,17 @@
   verbs:
   - '*'
 - apiGroups:
+  - "apps"
+  resources:
+  - statefulsets
+  verbs:
+  - '*'
+- apiGroups:
   - ""
   resources:
+  - configmaps
   - persistentvolumeclaims
+  - services
   verbs:
   - '*'
 - apiGroups:
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 ecbb32d..10095e6 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
@@ -16,6 +16,7 @@
 
 import com.google.common.flogger.FluentLogger;
 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 io.fabric8.kubernetes.client.Config;
 import io.fabric8.kubernetes.client.ConfigBuilder;
@@ -39,6 +40,8 @@
     operator.register(new GerritClusterReconciler(client));
     logger.atFine().log("Registering GitGc Reconciler");
     operator.register(new GitGarbageCollectionReconciler(client));
+    logger.atFine().log("Registering Gerrit Reconciler");
+    operator.register(new GerritReconciler());
     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 196a818..12dad89 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
@@ -74,9 +74,14 @@
 
   @JsonIgnore
   public VolumeMount getGitRepositoriesVolumeMount() {
+    return getGitRepositoriesVolumeMount("/var/mnt/git");
+  }
+
+  @JsonIgnore
+  public VolumeMount getGitRepositoriesVolumeMount(String mountPath) {
     return new VolumeMountBuilder()
         .withName(GIT_REPOSITORIES_VOLUME_NAME)
-        .withMountPath("/var/gerrit/git")
+        .withMountPath(mountPath)
         .build();
   }
 
@@ -92,10 +97,12 @@
 
   @JsonIgnore
   public VolumeMount getLogsVolumeMount() {
-    return new VolumeMountBuilder()
-        .withName(LOGS_VOLUME_NAME)
-        .withMountPath("/var/gerrit/logs")
-        .build();
+    return getLogsVolumeMount("/var/mnt/logs");
+  }
+
+  @JsonIgnore
+  public VolumeMount getLogsVolumeMount(String mountPath) {
+    return new VolumeMountBuilder().withName(LOGS_VOLUME_NAME).withMountPath(mountPath).build();
   }
 
   @JsonIgnore
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 ea5c2e4..6e62001 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
@@ -35,18 +35,23 @@
   private final KubernetesClient kubernetesClient;
 
   private NfsIdmapdConfigMap dependentNfsImapdConfigMap;
+  private PluginCachePVC dependentPluginCachePvc;
 
   public GerritClusterReconciler(KubernetesClient client) {
     this.kubernetesClient = client;
 
     this.dependentNfsImapdConfigMap = new NfsIdmapdConfigMap();
     this.dependentNfsImapdConfigMap.setKubernetesClient(kubernetesClient);
+
+    this.dependentPluginCachePvc = new PluginCachePVC();
+    this.dependentPluginCachePvc.setKubernetesClient(kubernetesClient);
   }
 
   @Override
   public Map<String, EventSource> prepareEventSources(EventSourceContext<GerritCluster> context) {
     return EventSourceInitializer.nameEventSources(
-        this.dependentNfsImapdConfigMap.initEventSource(context));
+        this.dependentNfsImapdConfigMap.initEventSource(context),
+        this.dependentPluginCachePvc.initEventSource(context));
   }
 
   @Override
@@ -58,6 +63,10 @@
       dependentNfsImapdConfigMap.reconcile(gerritCluster, context);
     }
 
+    if (gerritCluster.getSpec().getPluginCacheStorage().isEnabled()) {
+      dependentPluginCachePvc.reconcile(gerritCluster, context);
+    }
+
     return UpdateControl.noUpdate();
   }
 }
diff --git a/operator/src/main/java/com/google/gerrit/k8s/operator/cluster/GerritClusterSpec.java b/operator/src/main/java/com/google/gerrit/k8s/operator/cluster/GerritClusterSpec.java
index 8692bba..ca5a2a4 100644
--- a/operator/src/main/java/com/google/gerrit/k8s/operator/cluster/GerritClusterSpec.java
+++ b/operator/src/main/java/com/google/gerrit/k8s/operator/cluster/GerritClusterSpec.java
@@ -23,7 +23,8 @@
   private StorageClassConfig storageClasses;
   private SharedStorage gitRepositoryStorage;
   private SharedStorage logsStorage;
-  private String imagePullPolicy;
+  private OptionalSharedStorage pluginCacheStorage = new OptionalSharedStorage();
+  private String imagePullPolicy = "Always";
   private Set<LocalObjectReference> imagePullSecrets = new HashSet<>();
   private GerritRepositoryConfig gerritImages = new GerritRepositoryConfig();
   private BusyBoxImage busyBox = new BusyBoxImage();
@@ -52,6 +53,14 @@
     this.logsStorage = logsStorage;
   }
 
+  public OptionalSharedStorage getPluginCacheStorage() {
+    return pluginCacheStorage;
+  }
+
+  public void setPluginCacheStorage(OptionalSharedStorage pluginCacheStorage) {
+    this.pluginCacheStorage = pluginCacheStorage;
+  }
+
   public String getImagePullPolicy() {
     return imagePullPolicy;
   }
diff --git a/operator/src/main/java/com/google/gerrit/k8s/operator/cluster/OptionalSharedStorage.java b/operator/src/main/java/com/google/gerrit/k8s/operator/cluster/OptionalSharedStorage.java
new file mode 100644
index 0000000..1a20184
--- /dev/null
+++ b/operator/src/main/java/com/google/gerrit/k8s/operator/cluster/OptionalSharedStorage.java
@@ -0,0 +1,28 @@
+// 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 class OptionalSharedStorage extends SharedStorage {
+
+  private boolean enabled = false;
+
+  public boolean isEnabled() {
+    return enabled;
+  }
+
+  public void setEnabled(boolean enabled) {
+    this.enabled = enabled;
+  }
+}
diff --git a/operator/src/main/java/com/google/gerrit/k8s/operator/cluster/PluginCachePVC.java b/operator/src/main/java/com/google/gerrit/k8s/operator/cluster/PluginCachePVC.java
new file mode 100644
index 0000000..47d75da
--- /dev/null
+++ b/operator/src/main/java/com/google/gerrit/k8s/operator/cluster/PluginCachePVC.java
@@ -0,0 +1,59 @@
+// 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.PersistentVolumeClaim;
+import io.fabric8.kubernetes.api.model.PersistentVolumeClaimBuilder;
+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.Map;
+
+@KubernetesDependent(labelSelector = "app.kubernetes.io/component=gerrit-plugin-cache-storage")
+public class PluginCachePVC
+    extends CRUDKubernetesDependentResource<PersistentVolumeClaim, GerritCluster> {
+
+  public static final String PLUGIN_CACHE_PVC_NAME = "gerrit-plugin-cache-pvc";
+
+  public PluginCachePVC() {
+    super(PersistentVolumeClaim.class);
+  }
+
+  @Override
+  protected PersistentVolumeClaim desired(
+      GerritCluster gerritCluster, Context<GerritCluster> context) {
+    PersistentVolumeClaim gerritPluginCachePvc =
+        new PersistentVolumeClaimBuilder()
+            .withNewMetadata()
+            .withName(PLUGIN_CACHE_PVC_NAME)
+            .withNamespace(gerritCluster.getMetadata().getNamespace())
+            .withLabels(
+                gerritCluster.getLabels(PLUGIN_CACHE_PVC_NAME, this.getClass().getSimpleName()))
+            .endMetadata()
+            .withNewSpec()
+            .withAccessModes("ReadWriteMany")
+            .withNewResources()
+            .withRequests(
+                Map.of("storage", gerritCluster.getSpec().getPluginCacheStorage().getSize()))
+            .endResources()
+            .withStorageClassName(gerritCluster.getSpec().getStorageClasses().getReadWriteMany())
+            .withSelector(gerritCluster.getSpec().getPluginCacheStorage().getSelector())
+            .withVolumeName(gerritCluster.getSpec().getPluginCacheStorage().getVolumeName())
+            .endSpec()
+            .build();
+
+    return gerritPluginCachePvc;
+  }
+}
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
new file mode 100644
index 0000000..f4b2766
--- /dev/null
+++ b/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/Gerrit.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.gerrit;
+
+import io.fabric8.kubernetes.api.model.Namespaced;
+import io.fabric8.kubernetes.api.model.Status;
+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;
+import org.apache.commons.lang3.builder.ToStringBuilder;
+import org.apache.commons.lang3.builder.ToStringStyle;
+
+@Group("gerritoperator.google.com")
+@Version("v1alpha1")
+@ShortNames("gcr")
+public class Gerrit extends CustomResource<GerritSpec, Status> 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/gerrit/GerritConfigMapDependentResource.java b/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/GerritConfigMapDependentResource.java
new file mode 100644
index 0000000..a04fbcf
--- /dev/null
+++ b/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/GerritConfigMapDependentResource.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.gerrit;
+
+import com.google.gerrit.k8s.operator.cluster.GerritCluster;
+import io.fabric8.kubernetes.api.model.ConfigMap;
+import io.fabric8.kubernetes.api.model.ConfigMapBuilder;
+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.Map;
+
+@KubernetesDependent(labelSelector = "app.kubernetes.io/component=gerrit-configmap")
+public class GerritConfigMapDependentResource
+    extends CRUDKubernetesDependentResource<ConfigMap, Gerrit> {
+  private static final String DEFAULT_HEALTHCHECK_CONFIG =
+      "[healthcheck \"auth\"]\nenabled = false\n[healthcheck \"querychanges\"]\nenabled = false";
+  public static final String GERRIT_CONFIGMAP_NAME = "gerrit-configmap";
+
+  public GerritConfigMapDependentResource() {
+    super(ConfigMap.class);
+  }
+
+  @Override
+  protected ConfigMap desired(Gerrit gerrit, Context<Gerrit> context) {
+    GerritCluster gerritCluster =
+        client
+            .resources(GerritCluster.class)
+            .inNamespace(gerrit.getMetadata().getNamespace())
+            .withName(gerrit.getSpec().getCluster())
+            .get();
+    if (gerritCluster == null) {
+      throw new IllegalStateException("The Gerrit cluster could not be found.");
+    }
+
+    Map<String, String> gerritLabels =
+        gerritCluster.getLabels(GERRIT_CONFIGMAP_NAME, this.getClass().getSimpleName());
+
+    Map<String, String> configFiles = gerrit.getSpec().getConfigFiles();
+
+    if (!configFiles.containsKey("healthcheck.config")) {
+      configFiles.put("healthcheck.config", DEFAULT_HEALTHCHECK_CONFIG);
+    }
+
+    return new ConfigMapBuilder()
+        .withApiVersion("v1")
+        .withNewMetadata()
+        .withName(GERRIT_CONFIGMAP_NAME)
+        .withNamespace(gerrit.getMetadata().getNamespace())
+        .withLabels(gerritLabels)
+        .endMetadata()
+        .withData(configFiles)
+        .build();
+  }
+}
diff --git a/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/GerritInitConfig.java b/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/GerritInitConfig.java
new file mode 100644
index 0000000..94642f1
--- /dev/null
+++ b/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/GerritInitConfig.java
@@ -0,0 +1,74 @@
+// 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.gerrit;
+
+import java.util.Set;
+
+public class GerritInitConfig {
+  private String caCertPath = "/var/config/ca.crt";
+  private boolean pluginCacheEnabled;
+  private String pluginCacheDir = "/var/mnt/plugins";
+  private Set<String> packagedPlugins;
+  private Set<GerritPlugin> downloadedPlugins;
+  private Set<String> installAsLibrary;
+
+  public String getCaCertPath() {
+    return caCertPath;
+  }
+
+  public void setCaCertPath(String caCertPath) {
+    this.caCertPath = caCertPath;
+  }
+
+  public boolean isPluginCacheEnabled() {
+    return pluginCacheEnabled;
+  }
+
+  public void setPluginCacheEnabled(boolean pluginCacheEnabled) {
+    this.pluginCacheEnabled = pluginCacheEnabled;
+  }
+
+  public String getPluginCacheDir() {
+    return pluginCacheDir;
+  }
+
+  public void setPluginCacheDir(String pluginCacheDir) {
+    this.pluginCacheDir = pluginCacheDir;
+  }
+
+  public Set<String> getPackagedPlugins() {
+    return packagedPlugins;
+  }
+
+  public void setPackagedPlugins(Set<String> packagedPlugins) {
+    this.packagedPlugins = packagedPlugins;
+  }
+
+  public Set<GerritPlugin> getDownloadedPlugins() {
+    return downloadedPlugins;
+  }
+
+  public void setDownloadedPlugins(Set<GerritPlugin> downloadedPlugins) {
+    this.downloadedPlugins = downloadedPlugins;
+  }
+
+  public Set<String> getInstallAsLibrary() {
+    return installAsLibrary;
+  }
+
+  public void setInstallAsLibrary(Set<String> installAsLibrary) {
+    this.installAsLibrary = installAsLibrary;
+  }
+}
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
new file mode 100644
index 0000000..e2d7d62
--- /dev/null
+++ b/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/GerritInitConfigMapDependentResource.java
@@ -0,0 +1,94 @@
+// 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.gerrit;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
+import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator.Feature;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.k8s.operator.cluster.GerritCluster;
+import io.fabric8.kubernetes.api.model.ConfigMap;
+import io.fabric8.kubernetes.api.model.ConfigMapBuilder;
+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.Map;
+import java.util.stream.Collectors;
+
+@KubernetesDependent(labelSelector = "app.kubernetes.io/component=gerrit-init-configmap")
+public class GerritInitConfigMapDependentResource
+    extends CRUDKubernetesDependentResource<ConfigMap, Gerrit> {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+  public static final String GERRIT_INIT_CONFIGMAP_NAME = "gerrit-init-configmap";
+
+  public GerritInitConfigMapDependentResource() {
+    super(ConfigMap.class);
+  }
+
+  @Override
+  protected ConfigMap desired(Gerrit gerrit, Context<Gerrit> context) {
+    GerritCluster gerritCluster =
+        client
+            .resources(GerritCluster.class)
+            .inNamespace(gerrit.getMetadata().getNamespace())
+            .withName(gerrit.getSpec().getCluster())
+            .get();
+    if (gerritCluster == null) {
+      throw new IllegalStateException("The Gerrit cluster could not be found.");
+    }
+
+    Map<String, String> gerritLabels =
+        gerritCluster.getLabels(GERRIT_INIT_CONFIGMAP_NAME, this.getClass().getSimpleName());
+
+    return new ConfigMapBuilder()
+        .withApiVersion("v1")
+        .withNewMetadata()
+        .withName(GERRIT_INIT_CONFIGMAP_NAME)
+        .withNamespace(gerrit.getMetadata().getNamespace())
+        .withLabels(gerritLabels)
+        .endMetadata()
+        .withData(Map.of("gerrit-init.yaml", getGerritInitConfig(gerrit, gerritCluster)))
+        .build();
+  }
+
+  private String getGerritInitConfig(Gerrit gerrit, GerritCluster gerritCluster) {
+    GerritInitConfig config = new GerritInitConfig();
+    config.setDownloadedPlugins(
+        gerrit.getSpec().getPlugins().stream()
+            .filter(p -> !p.isPackagedPlugin())
+            .collect(Collectors.toSet()));
+    config.setPackagedPlugins(
+        gerrit.getSpec().getPlugins().stream()
+            .filter(p -> p.isPackagedPlugin())
+            .map(p -> p.getName())
+            .collect(Collectors.toSet()));
+    config.setPluginCacheEnabled(gerritCluster.getSpec().getPluginCacheStorage().isEnabled());
+    config.setInstallAsLibrary(
+        gerrit.getSpec().getPlugins().stream()
+            .filter(p -> p.isInstallAsLibrary())
+            .map(p -> p.getName())
+            .collect(Collectors.toSet()));
+
+    ObjectMapper mapper =
+        new ObjectMapper(new YAMLFactory().disable(Feature.WRITE_DOC_START_MARKER));
+    try {
+      return mapper.writeValueAsString(config);
+    } catch (JsonProcessingException e) {
+      logger.atSevere().withCause(e).log("Could not serialize gerrit-init.config");
+      throw new IllegalStateException(e);
+    }
+  }
+}
diff --git a/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/GerritPlugin.java b/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/GerritPlugin.java
new file mode 100644
index 0000000..df2d94f
--- /dev/null
+++ b/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/GerritPlugin.java
@@ -0,0 +1,76 @@
+// 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.gerrit;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import java.io.Serializable;
+import java.net.URL;
+
+class GerritPlugin implements Serializable {
+  private static final long serialVersionUID = 1L;
+
+  private String name;
+  private URL url;
+  private String sha1;
+  private boolean installAsLibrary = false;
+
+  public GerritPlugin(String name, URL url) {
+    this.name = name;
+    this.url = url;
+  }
+
+  public GerritPlugin(String name, URL url, String sha1) {
+    this.name = name;
+    this.url = url;
+    this.sha1 = sha1;
+  }
+
+  public String getName() {
+    return name;
+  }
+
+  public void setName(String name) {
+    this.name = name;
+  }
+
+  public URL getUrl() {
+    return url;
+  }
+
+  public void setUrl(URL url) {
+    this.url = url;
+  }
+
+  public String getSha1() {
+    return sha1;
+  }
+
+  public void setSha1(String sha1) {
+    this.sha1 = sha1;
+  }
+
+  public boolean isInstallAsLibrary() {
+    return installAsLibrary;
+  }
+
+  public void setInstallAsLibrary(boolean installAsLibrary) {
+    this.installAsLibrary = installAsLibrary;
+  }
+
+  @JsonIgnore
+  public boolean isPackagedPlugin() {
+    return this.url == null;
+  }
+}
diff --git a/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/GerritProbe.java b/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/GerritProbe.java
new file mode 100644
index 0000000..b03bd35
--- /dev/null
+++ b/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/GerritProbe.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.gerrit;
+
+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 GerritProbe extends Probe {
+  private static final long serialVersionUID = 1L;
+
+  private static final HTTPGetAction HTTP_GET_ACTION =
+      new HTTPGetActionBuilder()
+          .withPath("/config/server/healthcheck~status")
+          .withPort(new IntOrString(StatefulSetDependentResource.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/gerrit/GerritReconciler.java b/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/GerritReconciler.java
new file mode 100644
index 0000000..8d72042
--- /dev/null
+++ b/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/GerritReconciler.java
@@ -0,0 +1,43 @@
+// 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.gerrit;
+
+import io.javaoperatorsdk.operator.api.reconciler.Context;
+import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration;
+import io.javaoperatorsdk.operator.api.reconciler.Reconciler;
+import io.javaoperatorsdk.operator.api.reconciler.UpdateControl;
+import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent;
+
+@ControllerConfiguration(
+    dependents = {
+      @Dependent(name = "gerrit-configmap", type = GerritConfigMapDependentResource.class),
+      @Dependent(name = "gerrit-init-configmap", type = GerritInitConfigMapDependentResource.class),
+      @Dependent(
+          name = "gerrit-statefulset",
+          type = StatefulSetDependentResource.class,
+          dependsOn = {"gerrit-configmap", "gerrit-init-configmap"}),
+      @Dependent(
+          name = "gerrit-service",
+          type = ServiceDependentResource.class,
+          dependsOn = {"gerrit-statefulset"})
+    })
+public class GerritReconciler implements Reconciler<Gerrit> {
+
+  @Override
+  public UpdateControl<Gerrit> reconcile(Gerrit resource, Context<Gerrit> context)
+      throws Exception {
+    return UpdateControl.noUpdate();
+  }
+}
diff --git a/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/GerritServiceConfig.java b/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/GerritServiceConfig.java
new file mode 100644
index 0000000..50568f4
--- /dev/null
+++ b/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/GerritServiceConfig.java
@@ -0,0 +1,49 @@
+// 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.gerrit;
+
+import java.io.Serializable;
+
+class GerritServiceConfig implements Serializable {
+  private static final long serialVersionUID = 1L;
+
+  String type = "NodePort";
+  int httpPort = 80;
+  Integer sshPort;
+
+  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;
+  }
+
+  public Integer getSshPort() {
+    return sshPort;
+  }
+
+  public void setSshPort(int sshPort) {
+    this.sshPort = sshPort;
+  }
+}
diff --git a/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/GerritSite.java b/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/GerritSite.java
new file mode 100644
index 0000000..ba5b158
--- /dev/null
+++ b/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/GerritSite.java
@@ -0,0 +1,31 @@
+// 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.gerrit;
+
+import io.fabric8.kubernetes.api.model.Quantity;
+import java.io.Serializable;
+
+public class GerritSite implements Serializable {
+  private static final long serialVersionUID = 1L;
+  Quantity size;
+
+  public Quantity getSize() {
+    return size;
+  }
+
+  public void setSize(Quantity size) {
+    this.size = size;
+  }
+}
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
new file mode 100644
index 0000000..dbda4af
--- /dev/null
+++ b/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/GerritSpec.java
@@ -0,0 +1,188 @@
+// 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.gerrit;
+
+import io.fabric8.kubernetes.api.model.Affinity;
+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;
+import java.util.Map;
+import java.util.Set;
+
+public class GerritSpec {
+  private String cluster;
+
+  private List<Toleration> tolerations;
+  private Affinity affinity;
+  private List<TopologySpreadConstraint> topologySpreadConstraints = new ArrayList<>();
+  private String priorityClassName;
+
+  private int replicas = 1;
+  private int updatePartition = 0;
+
+  private ResourceRequirements resources;
+
+  private GerritProbe startupProbe = new GerritProbe();
+  private GerritProbe readinessProbe = new GerritProbe();
+  private GerritProbe livenessProbe = new GerritProbe();
+
+  private long gracefulStopTimeout;
+
+  private GerritServiceConfig service = new GerritServiceConfig();
+
+  private GerritSite site = new GerritSite();
+  private List<GerritPlugin> plugins = List.of();
+  private Map<String, String> configFiles = Map.of();
+  private Set<String> secrets = Set.of();
+
+  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 int getUpdatePartition() {
+    return updatePartition;
+  }
+
+  public void setUpdatePartition(int updatePartition) {
+    this.updatePartition = updatePartition;
+  }
+
+  public ResourceRequirements getResources() {
+    return resources;
+  }
+
+  public void setResources(ResourceRequirements resources) {
+    this.resources = resources;
+  }
+
+  public GerritProbe getStartupProbe() {
+    return startupProbe;
+  }
+
+  public void setStartupProbe(GerritProbe startupProbe) {
+    this.startupProbe = startupProbe;
+  }
+
+  public GerritProbe getReadinessProbe() {
+    return readinessProbe;
+  }
+
+  public void setReadinessProbe(GerritProbe readinessProbe) {
+    this.readinessProbe = readinessProbe;
+  }
+
+  public GerritProbe getLivenessProbe() {
+    return livenessProbe;
+  }
+
+  public void setLivenessProbe(GerritProbe livenessProbe) {
+    this.livenessProbe = livenessProbe;
+  }
+
+  public long getGracefulStopTimeout() {
+    return gracefulStopTimeout;
+  }
+
+  public void setGracefulStopTimeout(long gracefulStopTimeout) {
+    this.gracefulStopTimeout = gracefulStopTimeout;
+  }
+
+  public GerritServiceConfig getService() {
+    return service;
+  }
+
+  public void setService(GerritServiceConfig service) {
+    this.service = service;
+  }
+
+  public GerritSite getSite() {
+    return site;
+  }
+
+  public void setSite(GerritSite site) {
+    this.site = site;
+  }
+
+  public List<GerritPlugin> getPlugins() {
+    return plugins;
+  }
+
+  public void setPlugins(List<GerritPlugin> plugins) {
+    this.plugins = plugins;
+  }
+
+  public Map<String, String> getConfigFiles() {
+    return configFiles;
+  }
+
+  public void setConfigFiles(Map<String, String> configFiles) {
+    this.configFiles = configFiles;
+  }
+
+  public Set<String> getSecrets() {
+    return secrets;
+  }
+
+  public void setSecrets(Set<String> secrets) {
+    this.secrets = secrets;
+  }
+}
diff --git a/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/ServiceDependentResource.java b/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/ServiceDependentResource.java
new file mode 100644
index 0000000..b5955ad
--- /dev/null
+++ b/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/ServiceDependentResource.java
@@ -0,0 +1,89 @@
+// 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.gerrit;
+
+import static com.google.gerrit.k8s.operator.gerrit.StatefulSetDependentResource.HTTP_PORT;
+import static com.google.gerrit.k8s.operator.gerrit.StatefulSetDependentResource.SSH_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(labelSelector = "app.kubernetes.io/component=gerrit-service")
+public class ServiceDependentResource extends CRUDKubernetesDependentResource<Service, Gerrit> {
+  public static final String GERRIT_SERVICE_NAME = "gerrit-service";
+
+  public ServiceDependentResource() {
+    super(Service.class);
+  }
+
+  @Override
+  protected Service desired(Gerrit gerrit, Context<Gerrit> context) {
+    GerritCluster gerritCluster =
+        client
+            .resources(GerritCluster.class)
+            .inNamespace(gerrit.getMetadata().getNamespace())
+            .withName(gerrit.getSpec().getCluster())
+            .get();
+    if (gerritCluster == null) {
+      throw new IllegalStateException("The Gerrit cluster could not be found.");
+    }
+
+    return new ServiceBuilder()
+        .withApiVersion("v1")
+        .withNewMetadata()
+        .withName(GERRIT_SERVICE_NAME)
+        .withNamespace(gerrit.getMetadata().getNamespace())
+        .withLabels(getLabels(gerritCluster))
+        .endMetadata()
+        .withNewSpec()
+        .withType(gerrit.getSpec().getService().getType())
+        .withPorts(getServicePorts(gerrit))
+        .withSelector(StatefulSetDependentResource.getLabels(gerritCluster, gerrit))
+        .endSpec()
+        .build();
+  }
+
+  public static Map<String, String> getLabels(GerritCluster gerritCluster) {
+    return gerritCluster.getLabels("gerrit-service", GerritReconciler.class.getSimpleName());
+  }
+
+  private static List<ServicePort> getServicePorts(Gerrit gerrit) {
+    List<ServicePort> ports = new ArrayList<>();
+    ports.add(
+        new ServicePortBuilder()
+            .withName("http")
+            .withPort(gerrit.getSpec().getService().getHttpPort())
+            .withNewTargetPort(HTTP_PORT)
+            .build());
+    if (gerrit.getSpec().getService().getSshPort() > 0) {
+      ports.add(
+          new ServicePortBuilder()
+              .withName("ssh")
+              .withPort(gerrit.getSpec().getService().getSshPort())
+              .withNewTargetPort(SSH_PORT)
+              .build());
+    }
+    return ports;
+  }
+}
diff --git a/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/StatefulSetDependentResource.java b/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/StatefulSetDependentResource.java
new file mode 100644
index 0000000..ea76877
--- /dev/null
+++ b/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/StatefulSetDependentResource.java
@@ -0,0 +1,241 @@
+// 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.gerrit;
+
+import com.google.gerrit.k8s.operator.cluster.GerritCluster;
+import com.google.gerrit.k8s.operator.cluster.PluginCachePVC;
+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.StatefulSet;
+import io.fabric8.kubernetes.api.model.apps.StatefulSetBuilder;
+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 StatefulSetDependentResource
+    extends CRUDKubernetesDependentResource<StatefulSet, Gerrit> {
+
+  private static final String SITE_VOLUME_NAME = "gerrit-site";
+  public static final int HTTP_PORT = 8080;
+  public static final int SSH_PORT = 29418;
+
+  public StatefulSetDependentResource() {
+    super(StatefulSet.class);
+  }
+
+  @Override
+  protected StatefulSet desired(Gerrit gerrit, Context<Gerrit> context) {
+    // TODO(Thomas): data files,  image version, CA cert
+
+    GerritCluster gerritCluster =
+        client
+            .resources(GerritCluster.class)
+            .inNamespace(gerrit.getMetadata().getNamespace())
+            .withName(gerrit.getSpec().getCluster())
+            .get();
+
+    if (gerritCluster == null) {
+      throw new IllegalStateException("The Gerrit cluster could not be found.");
+    }
+
+    StatefulSetBuilder stsBuilder = new StatefulSetBuilder();
+
+    stsBuilder
+        .withApiVersion("apps/v1")
+        .withNewMetadata()
+        .withName(gerrit.getMetadata().getName())
+        .withNamespace(gerrit.getMetadata().getNamespace())
+        .withLabels(getLabels(gerritCluster, gerrit))
+        .endMetadata()
+        .withNewSpec()
+        .withServiceName(String.format("%s-service", gerrit.getMetadata().getName()))
+        .withReplicas(gerrit.getSpec().getReplicas())
+        .withNewUpdateStrategy()
+        .withNewRollingUpdate(gerrit.getSpec().getUpdatePartition())
+        .endUpdateStrategy()
+        .withNewSelector()
+        .withMatchLabels(getLabels(gerritCluster, gerrit))
+        .endSelector()
+        .withNewTemplate()
+        .withNewMetadata()
+        .withLabels(getLabels(gerritCluster, gerrit))
+        .endMetadata()
+        .withNewSpec()
+        .withTolerations(gerrit.getSpec().getTolerations())
+        .withTopologySpreadConstraints(gerrit.getSpec().getTopologySpreadConstraints())
+        .withAffinity(gerrit.getSpec().getAffinity())
+        .withPriorityClassName(gerrit.getSpec().getPriorityClassName())
+        .withTerminationGracePeriodSeconds(gerrit.getSpec().getGracefulStopTimeout())
+        .addAllToImagePullSecrets(gerritCluster.getSpec().getImagePullSecrets())
+        .withNewSecurityContext()
+        .withFsGroup(100L)
+        .endSecurityContext()
+        .addNewInitContainer()
+        .withName("gerrit-init")
+        .withImagePullPolicy(gerritCluster.getSpec().getImagePullPolicy())
+        .withImage(gerritCluster.getSpec().getGerritImages().getFullImageName("gerrit-init"))
+        .withResources(gerrit.getSpec().getResources())
+        .addAllToVolumeMounts(getVolumeMounts(gerrit, gerritCluster, true))
+        .endInitContainer()
+        .addNewContainer()
+        .withName("gerrit")
+        .withImagePullPolicy(gerritCluster.getSpec().getImagePullPolicy())
+        .withImage(gerritCluster.getSpec().getGerritImages().getFullImageName("gerrit"))
+        .withNewLifecycle()
+        .withNewPreStop()
+        .withNewExec()
+        .withCommand(
+            "/bin/ash/", "-c", "kill -2 $(pidof java) && tail --pid=$(pidof java) -f /dev/null")
+        .endExec()
+        .endPreStop()
+        .endLifecycle()
+        .withPorts(getContainerPorts(gerrit))
+        .withResources(gerrit.getSpec().getResources())
+        .withStartupProbe(gerrit.getSpec().getStartupProbe())
+        .withReadinessProbe(gerrit.getSpec().getReadinessProbe())
+        .withLivenessProbe(gerrit.getSpec().getLivenessProbe())
+        .addAllToVolumeMounts(getVolumeMounts(gerrit, gerritCluster, false))
+        .endContainer()
+        .addAllToVolumes(getVolumes(gerrit, gerritCluster))
+        .endSpec()
+        .endTemplate()
+        .addNewVolumeClaimTemplate()
+        .withNewMetadata()
+        .withName(SITE_VOLUME_NAME)
+        .withLabels(getLabels(gerritCluster, gerrit))
+        .endMetadata()
+        .withNewSpec()
+        .withAccessModes("ReadWriteOnce")
+        .withNewResources()
+        .withRequests(Map.of("storage", gerrit.getSpec().getSite().getSize()))
+        .endResources()
+        .withStorageClassName(gerritCluster.getSpec().getStorageClasses().getReadWriteOnce())
+        .endSpec()
+        .endVolumeClaimTemplate()
+        .endSpec();
+
+    return stsBuilder.build();
+  }
+
+  public static Map<String, String> getLabels(GerritCluster gerritCluster, Gerrit gerrit) {
+    return gerritCluster.getLabels(
+        String.format("gerrit-statefulset-%s", gerrit.getMetadata().getName()),
+        GerritReconciler.class.getSimpleName());
+  }
+
+  private Set<Volume> getVolumes(Gerrit gerrit, GerritCluster gerritCluster) {
+    Set<Volume> volumes = new HashSet<>();
+
+    volumes.add(gerritCluster.getGitRepositoriesVolume());
+
+    volumes.add(
+        new VolumeBuilder()
+            .withName("gerrit-init-config")
+            .withNewConfigMap()
+            .withName(GerritInitConfigMapDependentResource.GERRIT_INIT_CONFIGMAP_NAME)
+            .endConfigMap()
+            .build());
+
+    volumes.add(
+        new VolumeBuilder()
+            .withName("gerrit-config")
+            .withNewConfigMap()
+            .withName(GerritConfigMapDependentResource.GERRIT_CONFIGMAP_NAME)
+            .endConfigMap()
+            .build());
+
+    for (String secretName : gerrit.getSpec().getSecrets()) {
+      volumes.add(
+          new VolumeBuilder()
+              .withName(secretName)
+              .withNewSecret()
+              .withSecretName(secretName)
+              .endSecret()
+              .build());
+    }
+
+    if (gerritCluster.getSpec().getPluginCacheStorage().isEnabled()
+        && gerrit.getSpec().getPlugins().stream().anyMatch(p -> !p.isPackagedPlugin())) {
+      volumes.add(
+          new VolumeBuilder()
+              .withName("gerrit-plugin-cache")
+              .withNewPersistentVolumeClaim()
+              .withClaimName(PluginCachePVC.PLUGIN_CACHE_PVC_NAME)
+              .endPersistentVolumeClaim()
+              .build());
+    }
+
+    return volumes;
+  }
+
+  private Set<VolumeMount> getVolumeMounts(
+      Gerrit gerrit, GerritCluster gerritCluster, boolean isInitContainer) {
+    Set<VolumeMount> volumeMounts = new HashSet<>();
+    volumeMounts.add(
+        new VolumeMountBuilder().withName(SITE_VOLUME_NAME).withMountPath("/var/gerrit").build());
+    volumeMounts.add(gerritCluster.getGitRepositoriesVolumeMount());
+    volumeMounts.add(
+        new VolumeMountBuilder()
+            .withName("gerrit-config")
+            .withMountPath("/var/mnt/etc/config")
+            .build());
+
+    for (String secretName : gerrit.getSpec().getSecrets()) {
+      volumeMounts.add(
+          new VolumeMountBuilder()
+              .withName(secretName)
+              .withMountPath("/var/mnt/etc/secret")
+              .build());
+    }
+
+    if (isInitContainer) {
+      volumeMounts.add(
+          new VolumeMountBuilder()
+              .withName("gerrit-init-config")
+              .withMountPath("/var/config")
+              .build());
+
+      if (gerritCluster.getSpec().getPluginCacheStorage().isEnabled()
+          && gerrit.getSpec().getPlugins().stream().anyMatch(p -> !p.isPackagedPlugin())) {
+        volumeMounts.add(
+            new VolumeMountBuilder()
+                .withName("gerrit-plugin-cache")
+                .withMountPath("/var/mnt/plugins")
+                .build());
+      }
+    }
+    return volumeMounts;
+  }
+
+  private List<ContainerPort> getContainerPorts(Gerrit gerrit) {
+    List<ContainerPort> containerPorts = new ArrayList<>();
+    containerPorts.add(new ContainerPort(HTTP_PORT, null, null, "http", null));
+
+    if (gerrit.getSpec().getService().getSshPort() != null) {
+      containerPorts.add(new ContainerPort(SSH_PORT, null, null, "ssh", null));
+    }
+
+    return containerPorts;
+  }
+}
diff --git a/operator/src/main/java/com/google/gerrit/k8s/operator/gitgc/GitGarbageCollectionCronJob.java b/operator/src/main/java/com/google/gerrit/k8s/operator/gitgc/GitGarbageCollectionCronJob.java
index 5317506..4c2e5e4 100644
--- a/operator/src/main/java/com/google/gerrit/k8s/operator/gitgc/GitGarbageCollectionCronJob.java
+++ b/operator/src/main/java/com/google/gerrit/k8s/operator/gitgc/GitGarbageCollectionCronJob.java
@@ -156,7 +156,9 @@
 
   private Container buildGitGcContainer(GitGarbageCollection gitGc, GerritCluster gerritCluster) {
     List<VolumeMount> volumeMounts =
-        List.of(gerritCluster.getGitRepositoriesVolumeMount(), gerritCluster.getLogsVolumeMount());
+        List.of(
+            gerritCluster.getGitRepositoriesVolumeMount("/var/gerrit/git"),
+            gerritCluster.getLogsVolumeMount("/var/log/git"));
 
     if (gerritCluster.getSpec().getStorageClasses().getNfsWorkaround().isEnabled()
         && gerritCluster.getSpec().getStorageClasses().getNfsWorkaround().getIdmapdConfig()
diff --git a/operator/src/main/java/com/google/gerrit/k8s/operator/test/Util.java b/operator/src/main/java/com/google/gerrit/k8s/operator/test/Util.java
deleted file mode 100644
index f2d2101..0000000
--- a/operator/src/main/java/com/google/gerrit/k8s/operator/test/Util.java
+++ /dev/null
@@ -1,42 +0,0 @@
-// 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 com.google.common.flogger.FluentLogger;
-import io.fabric8.kubernetes.client.Config;
-import io.fabric8.kubernetes.client.DefaultKubernetesClient;
-import io.fabric8.kubernetes.client.KubernetesClient;
-import java.io.IOException;
-import java.nio.file.Files;
-import java.nio.file.Path;
-
-public class Util {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  public static KubernetesClient getKubernetesClient() {
-    Config config;
-    try {
-      String kubeconfig = System.getenv("KUBECONFIG");
-      if (kubeconfig != null) {
-        config = Config.fromKubeconfig(Files.readString(Path.of(kubeconfig)));
-        return new DefaultKubernetesClient(config);
-      }
-      logger.atWarning().log("KUBECONFIG variable not set. Using default config.");
-    } catch (IOException e) {
-      logger.atSevere().log("Failed to load kubeconfig. Trying default", e);
-    }
-    return new DefaultKubernetesClient();
-  }
-}
diff --git a/operator/src/test/java/com/google/gerrit/k8s/operator/cluster/GerritClusterE2E.java b/operator/src/test/java/com/google/gerrit/k8s/operator/cluster/GerritClusterE2E.java
index 4dbeebd..0d3efc3 100644
--- a/operator/src/test/java/com/google/gerrit/k8s/operator/cluster/GerritClusterE2E.java
+++ b/operator/src/test/java/com/google/gerrit/k8s/operator/cluster/GerritClusterE2E.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.k8s.operator.cluster;
 
+import static com.google.gerrit.k8s.operator.test.Util.createCluster;
 import static com.google.gerrit.k8s.operator.test.Util.getKubernetesClient;
 import static java.util.concurrent.TimeUnit.MINUTES;
 import static org.awaitility.Awaitility.await;
@@ -23,9 +24,7 @@
 
 import com.google.common.flogger.FluentLogger;
 import io.fabric8.kubernetes.api.model.ConfigMap;
-import io.fabric8.kubernetes.api.model.ObjectMetaBuilder;
 import io.fabric8.kubernetes.api.model.PersistentVolumeClaim;
-import io.fabric8.kubernetes.api.model.Quantity;
 import io.fabric8.kubernetes.client.KubernetesClient;
 import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension;
 import org.junit.jupiter.api.AfterEach;
@@ -45,7 +44,7 @@
 
   @Test
   void testGitRepositoriesPvcCreated() {
-    GerritCluster cluster = createGerritCluster(false);
+    GerritCluster cluster = createCluster(client, operator.getNamespace(), false);
 
     logger.atInfo().log("Waiting max 1 minutes for the git repositories pvc to be created.");
     await()
@@ -67,7 +66,7 @@
 
   @Test
   void testGerritLogsPvcCreated() {
-    GerritCluster cluster = createGerritCluster(false);
+    GerritCluster cluster = createCluster(client, operator.getNamespace(), false);
 
     logger.atInfo().log("Waiting max 1 minutes for the gerrit logs pvc to be created.");
     await()
@@ -89,7 +88,7 @@
 
   @Test
   void testNfsIdmapdConfigMapCreated() {
-    GerritCluster cluster = createGerritCluster(true);
+    GerritCluster cluster = createCluster(client, operator.getNamespace(), true);
 
     logger.atInfo().log("Waiting max 1 minutes for the nfs idmapd configmap to be created.");
     await()
@@ -112,42 +111,4 @@
   void cleanup() {
     client.resources(GerritCluster.class).inNamespace(operator.getNamespace()).delete();
   }
-
-  private GerritCluster createGerritCluster(boolean isNfsEnbaled) {
-    GerritCluster cluster = new GerritCluster();
-
-    cluster.setMetadata(
-        new ObjectMetaBuilder()
-            .withName("test-cluster")
-            .withNamespace(operator.getNamespace())
-            .build());
-
-    SharedStorage repoStorage = new SharedStorage();
-    repoStorage.setSize(Quantity.parse("1Gi"));
-
-    SharedStorage logStorage = new SharedStorage();
-    logStorage.setSize(Quantity.parse("1Gi"));
-
-    StorageClassConfig storageClassConfig = new StorageClassConfig();
-    storageClassConfig.setReadWriteMany(System.getProperty("rwmStorageClass", "nfs-client"));
-
-    NfsWorkaroundConfig nfsWorkaround = new NfsWorkaroundConfig();
-    nfsWorkaround.setEnabled(isNfsEnbaled);
-    nfsWorkaround.setIdmapdConfig("[General]\nDomain = localdomain.com");
-    storageClassConfig.setNfsWorkaround(nfsWorkaround);
-
-    GerritClusterSpec clusterSpec = new GerritClusterSpec();
-    clusterSpec.setGitRepositoryStorage(repoStorage);
-    clusterSpec.setLogsStorage(logStorage);
-    clusterSpec.setStorageClasses(storageClassConfig);
-
-    cluster.setSpec(clusterSpec);
-
-    client
-        .resources(GerritCluster.class)
-        .inNamespace(operator.getNamespace())
-        .createOrReplace(cluster);
-
-    return cluster;
-  }
 }
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
new file mode 100644
index 0000000..27b0da9
--- /dev/null
+++ b/operator/src/test/java/com/google/gerrit/k8s/operator/gerrit/GerritE2E.java
@@ -0,0 +1,188 @@
+// 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.gerrit;
+
+import static com.google.gerrit.k8s.operator.gerrit.ServiceDependentResource.GERRIT_SERVICE_NAME;
+import static com.google.gerrit.k8s.operator.test.Util.createCluster;
+import static com.google.gerrit.k8s.operator.test.Util.createImagePullSecret;
+import static com.google.gerrit.k8s.operator.test.Util.getKubernetesClient;
+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.k8s.operator.cluster.GerritCluster;
+import com.google.gerrit.k8s.operator.cluster.GerritClusterReconciler;
+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.GerritReconciler;
+import com.google.gerrit.k8s.operator.gerrit.GerritSite;
+import com.google.gerrit.k8s.operator.gerrit.GerritSpec;
+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.client.KubernetesClient;
+import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension;
+import java.util.Map;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+public class GerritE2E {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+  private static final KubernetesClient client = getKubernetesClient();
+
+  @RegisterExtension
+  LocallyRunOperatorExtension operator =
+      LocallyRunOperatorExtension.builder()
+          .waitForNamespaceDeletion(true)
+          .withReconciler(new GerritClusterReconciler(client))
+          .withReconciler(new GerritReconciler())
+          .build();
+
+  @BeforeEach
+  void setup() {
+    createImagePullSecret(client, operator.getNamespace());
+  }
+
+  @Test
+  void testGerritStatefulSetCreated() {
+    GerritCluster cluster = createCluster(client, operator.getNamespace());
+
+    Gerrit gerrit = new Gerrit();
+    ObjectMeta gerritMeta =
+        new ObjectMetaBuilder().withName("gerrit").withNamespace(operator.getNamespace()).build();
+    gerrit.setMetadata(gerritMeta);
+    GerritSpec gerritSpec = new GerritSpec();
+    gerritSpec.setCluster(cluster.getMetadata().getName());
+    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.setConfigFiles(
+        Map.of(
+            "gerrit.config",
+            "        [gerrit]\n"
+                + "          basePath = git\n"
+                + "          serverId = gerrit-1\n"
+                + "          canonicalWebUrl = http://example.com/\n"
+                + "        [index]\n"
+                + "          type = LUCENE\n"
+                + "          onlineUpgrade = false\n"
+                + "        [auth]\n"
+                + "          type = DEVELOPMENT_BECOME_ANY_ACCOUNT\n"
+                + "        [httpd]\n"
+                + "          listenUrl = proxy-http://*: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"
+                + "          user = gerrit\n"
+                + "          javaHome = /usr/lib/jvm/java-11-openjdk\n"
+                + "          javaOptions = -Djavax.net.ssl.trustStore=/var/gerrit/etc/keystore\n"
+                + "          javaOptions = -Xmx4g"));
+
+    gerrit.setSpec(gerritSpec);
+    client.resource(gerrit).createOrReplace();
+
+    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.GERRIT_CONFIGMAP_NAME)
+                      .get(),
+                  is(notNullValue()));
+              assertThat(
+                  client
+                      .configMaps()
+                      .inNamespace(operator.getNamespace())
+                      .withName(GerritInitConfigMapDependentResource.GERRIT_INIT_CONFIGMAP_NAME)
+                      .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(GERRIT_SERVICE_NAME)
+                      .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());
+            });
+  }
+
+  @AfterEach
+  void cleanup() {
+    client.resources(Gerrit.class).inNamespace(operator.getNamespace()).delete();
+    client.resources(GerritCluster.class).inNamespace(operator.getNamespace()).delete();
+  }
+}
diff --git a/operator/src/test/java/com/google/gerrit/k8s/operator/gitgc/GitGarbageCollectionE2E.java b/operator/src/test/java/com/google/gerrit/k8s/operator/gitgc/GitGarbageCollectionE2E.java
index 1a3f0a3..dc4ae40 100644
--- a/operator/src/test/java/com/google/gerrit/k8s/operator/gitgc/GitGarbageCollectionE2E.java
+++ b/operator/src/test/java/com/google/gerrit/k8s/operator/gitgc/GitGarbageCollectionE2E.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.k8s.operator.gitgc;
 
+import static com.google.gerrit.k8s.operator.test.Util.CLUSTER_NAME;
+import static com.google.gerrit.k8s.operator.test.Util.createCluster;
 import static com.google.gerrit.k8s.operator.test.Util.getKubernetesClient;
 import static java.util.concurrent.TimeUnit.MINUTES;
 import static org.awaitility.Awaitility.await;
@@ -27,12 +29,8 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.k8s.operator.cluster.GerritCluster;
 import com.google.gerrit.k8s.operator.cluster.GerritClusterReconciler;
-import com.google.gerrit.k8s.operator.cluster.GerritClusterSpec;
-import com.google.gerrit.k8s.operator.cluster.SharedStorage;
-import com.google.gerrit.k8s.operator.cluster.StorageClassConfig;
 import com.google.gerrit.k8s.operator.gitgc.GitGarbageCollectionStatus.GitGcState;
 import io.fabric8.kubernetes.api.model.ObjectMetaBuilder;
-import io.fabric8.kubernetes.api.model.Quantity;
 import io.fabric8.kubernetes.api.model.batch.v1.CronJob;
 import io.fabric8.kubernetes.api.model.batch.v1.Job;
 import io.fabric8.kubernetes.client.KubernetesClient;
@@ -46,7 +44,6 @@
 public class GitGarbageCollectionE2E {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
   static final String GITGC_SCHEDULE = "*/1 * * * *";
-  static final String CLUSTER_NAME = "test-cluster";
 
   static final KubernetesClient client = getKubernetesClient();
 
@@ -60,7 +57,7 @@
 
   @Test
   void testGitGcAllProjectsCreationAndDeletion() {
-    createCluster();
+    createCluster(client, operator.getNamespace());
     GitGarbageCollection gitGc = createCompleteGc();
 
     logger.atInfo().log("Waiting max 2 minutes for GitGc to be created.");
@@ -80,7 +77,7 @@
 
   @Test
   void testGitGcSelectedProjects() {
-    createCluster();
+    createCluster(client, operator.getNamespace());
     GitGarbageCollection gitGc = createSelectiveGc("selective-gc", Set.of("All-Projects", "test"));
 
     logger.atInfo().log("Waiting max 2 minutes for GitGc to be created.");
@@ -98,7 +95,7 @@
 
   @Test
   void testSelectiveGcIsExcludedFromCompleteGc() {
-    createCluster();
+    createCluster(client, operator.getNamespace());
     GitGarbageCollection completeGitGc = createCompleteGc();
 
     logger.atInfo().log("Waiting max 2 minutes for GitGc to be created.");
@@ -157,7 +154,7 @@
 
   @Test
   void testConflictingSelectiveGcFailsBeforeCronJobCreation() throws InterruptedException {
-    createCluster();
+    createCluster(client, operator.getNamespace());
     Set<String> selectedProjects = Set.of("All-Projects", "test");
     GitGarbageCollection selectiveGitGc1 = createSelectiveGc("selective-gc-1", selectedProjects);
 
@@ -201,51 +198,6 @@
     client.resources(GerritCluster.class).inNamespace(operator.getNamespace()).delete();
   }
 
-  private void createCluster() {
-    GerritCluster cluster = new GerritCluster();
-
-    cluster.setMetadata(
-        new ObjectMetaBuilder()
-            .withName(CLUSTER_NAME)
-            .withNamespace(operator.getNamespace())
-            .build());
-
-    SharedStorage repoStorage = new SharedStorage();
-    repoStorage.setSize(Quantity.parse("1Gi"));
-
-    SharedStorage logStorage = new SharedStorage();
-    logStorage.setSize(Quantity.parse("1Gi"));
-
-    StorageClassConfig storageClassConfig = new StorageClassConfig();
-    storageClassConfig.setReadWriteMany(System.getProperty("rwmStorageClass", "nfs-client"));
-
-    GerritClusterSpec clusterSpec = new GerritClusterSpec();
-    clusterSpec.setGitRepositoryStorage(repoStorage);
-    clusterSpec.setLogsStorage(logStorage);
-    clusterSpec.setStorageClasses(storageClassConfig);
-
-    cluster.setSpec(clusterSpec);
-    logger.atInfo().log(cluster.toString());
-
-    client
-        .resources(GerritCluster.class)
-        .inNamespace(operator.getNamespace())
-        .createOrReplace(cluster);
-
-    await()
-        .atMost(1, MINUTES)
-        .untilAsserted(
-            () -> {
-              GerritCluster updatedCluster =
-                  client
-                      .resources(GerritCluster.class)
-                      .inNamespace(operator.getNamespace())
-                      .withName(CLUSTER_NAME)
-                      .get();
-              assertThat(updatedCluster, is(notNullValue()));
-            });
-  }
-
   private GitGarbageCollection createCompleteGc() {
     GitGarbageCollection gitGc = new GitGarbageCollection();
     gitGc.setMetadata(
diff --git a/operator/src/test/java/com/google/gerrit/k8s/operator/test/Util.java b/operator/src/test/java/com/google/gerrit/k8s/operator/test/Util.java
new file mode 100644
index 0000000..0991a66
--- /dev/null
+++ b/operator/src/test/java/com/google/gerrit/k8s/operator/test/Util.java
@@ -0,0 +1,167 @@
+// 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 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 com.google.common.flogger.FluentLogger;
+import com.google.gerrit.k8s.operator.cluster.GerritCluster;
+import com.google.gerrit.k8s.operator.cluster.GerritClusterSpec;
+import com.google.gerrit.k8s.operator.cluster.GerritRepositoryConfig;
+import com.google.gerrit.k8s.operator.cluster.NfsWorkaroundConfig;
+import com.google.gerrit.k8s.operator.cluster.SharedStorage;
+import com.google.gerrit.k8s.operator.cluster.StorageClassConfig;
+import io.fabric8.kubernetes.api.model.LocalObjectReference;
+import io.fabric8.kubernetes.api.model.ObjectMetaBuilder;
+import io.fabric8.kubernetes.api.model.Quantity;
+import io.fabric8.kubernetes.api.model.Secret;
+import io.fabric8.kubernetes.api.model.SecretBuilder;
+import io.fabric8.kubernetes.client.Config;
+import io.fabric8.kubernetes.client.DefaultKubernetesClient;
+import io.fabric8.kubernetes.client.KubernetesClient;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Base64;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+public class Util {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+  public static final String CLUSTER_NAME = "test-cluster";
+  public static final String IMAGE_PULL_SECRET_NAME = "image-pull-secret";
+
+  public static KubernetesClient getKubernetesClient() {
+    Config config;
+    try {
+      String kubeconfig = System.getenv("KUBECONFIG");
+      if (kubeconfig != null) {
+        config = Config.fromKubeconfig(Files.readString(Path.of(kubeconfig)));
+        return new DefaultKubernetesClient(config);
+      }
+      logger.atWarning().log("KUBECONFIG variable not set. Using default config.");
+    } catch (IOException e) {
+      logger.atSevere().log("Failed to load kubeconfig. Trying default", e);
+    }
+    return new DefaultKubernetesClient();
+  }
+
+  public static GerritCluster createCluster(KubernetesClient client, String namespace) {
+    return createCluster(client, namespace, false);
+  }
+
+  public static GerritCluster createCluster(
+      KubernetesClient client, String namespace, boolean isNfsEnbaled) {
+    GerritCluster cluster = new GerritCluster();
+
+    cluster.setMetadata(
+        new ObjectMetaBuilder().withName(CLUSTER_NAME).withNamespace(namespace).build());
+
+    SharedStorage repoStorage = new SharedStorage();
+    repoStorage.setSize(Quantity.parse("1Gi"));
+
+    SharedStorage logStorage = new SharedStorage();
+    logStorage.setSize(Quantity.parse("1Gi"));
+
+    StorageClassConfig storageClassConfig = new StorageClassConfig();
+    storageClassConfig.setReadWriteMany(System.getProperty("rwmStorageClass", "nfs-client"));
+
+    NfsWorkaroundConfig nfsWorkaround = new NfsWorkaroundConfig();
+    nfsWorkaround.setEnabled(isNfsEnbaled);
+    nfsWorkaround.setIdmapdConfig("[General]\nDomain = localdomain.com");
+    storageClassConfig.setNfsWorkaround(nfsWorkaround);
+
+    GerritClusterSpec clusterSpec = new GerritClusterSpec();
+    clusterSpec.setGitRepositoryStorage(repoStorage);
+    clusterSpec.setLogsStorage(logStorage);
+    clusterSpec.setStorageClasses(storageClassConfig);
+
+    GerritRepositoryConfig repoConfig = new GerritRepositoryConfig();
+    repoConfig.setOrg(getContainerRegistryOrg());
+    repoConfig.setRegistry(getContainerRegistry());
+    repoConfig.setTag(getContainerTag());
+    clusterSpec.setGerritImages(repoConfig);
+    Set<LocalObjectReference> imagePullSecrets = new HashSet<>();
+    imagePullSecrets.add(new LocalObjectReference(IMAGE_PULL_SECRET_NAME));
+    clusterSpec.setImagePullSecrets(imagePullSecrets);
+
+    cluster.setSpec(clusterSpec);
+    client.resources(GerritCluster.class).inNamespace(namespace).createOrReplace(cluster);
+
+    await()
+        .atMost(1, MINUTES)
+        .untilAsserted(
+            () -> {
+              GerritCluster updatedCluster =
+                  client
+                      .resources(GerritCluster.class)
+                      .inNamespace(namespace)
+                      .withName(CLUSTER_NAME)
+                      .get();
+              assertThat(updatedCluster, is(notNullValue()));
+            });
+    return cluster;
+  }
+
+  public static void createImagePullSecret(KubernetesClient client, String namespace) {
+    StringBuilder secretBuilder = new StringBuilder();
+    secretBuilder.append("{\"auths\": {\"");
+    secretBuilder.append(getContainerRegistry());
+    secretBuilder.append("\": {\"auth\": \"");
+    secretBuilder.append(
+        Base64.getEncoder()
+            .encodeToString(
+                String.format("%s:%s", getContainerRegistryUser(), getContainerRegistryPassword())
+                    .getBytes()));
+    secretBuilder.append("\"}}}");
+    String data = Base64.getEncoder().encodeToString(secretBuilder.toString().getBytes());
+
+    Secret imagePullSecret =
+        new SecretBuilder()
+            .withType("kubernetes.io/dockerconfigjson")
+            .withNewMetadata()
+            .withName(IMAGE_PULL_SECRET_NAME)
+            .withNamespace(namespace)
+            .endMetadata()
+            .withData(Map.of(".dockerconfigjson", data))
+            .build();
+    client.secrets().create(imagePullSecret);
+  }
+
+  public static String getContainerRegistry() {
+    return System.getProperty("registry", "");
+  }
+
+  public static String getContainerRegistryUser() {
+    return System.getProperty("registryUser", "");
+  }
+
+  public static String getContainerRegistryPassword() {
+    return System.getProperty("registryPwd", "");
+  }
+
+  public static String getContainerRegistryOrg() {
+    return System.getProperty("registryOrg", "k8sgerrit");
+  }
+
+  public static String getContainerTag() {
+    return System.getProperty("tag", "latest");
+  }
+}