update charts
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/Constants.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/Constants.java
new file mode 100644
index 0000000..d2fb405
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/Constants.java
@@ -0,0 +1,23 @@
+// Copyright (C) 2023 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;
+
+import com.google.inject.AbstractModule;
+
+public class Constants extends AbstractModule {
+  public static final String[] VERSIONS = new String[] {"v1alpha"};
+  public static final String[] CUSTOM_RESOURCES =
+      new String[] {"GerritCluster", "Gerrit", "Receiver", "GerritNetwork", "GitGarbageCollection"};
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/EnvModule.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/EnvModule.java
new file mode 100644
index 0000000..1f5b04c
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/EnvModule.java
@@ -0,0 +1,35 @@
+// Copyright (C) 2023 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;
+
+import com.google.gerrit.k8s.operator.network.IngressType;
+import com.google.inject.AbstractModule;
+import com.google.inject.name.Names;
+
+public class EnvModule extends AbstractModule {
+  @Override
+  protected void configure() {
+    bind(String.class)
+        .annotatedWith(Names.named("Namespace"))
+        .toInstance(System.getenv("NAMESPACE"));
+
+    String ingressTypeEnv = System.getenv("INGRESS");
+    IngressType ingressType =
+        ingressTypeEnv == null
+            ? IngressType.NONE
+            : IngressType.valueOf(ingressTypeEnv.toUpperCase());
+    bind(IngressType.class).annotatedWith(Names.named("IngressType")).toInstance(ingressType);
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/GerritOperator.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/GerritOperator.java
new file mode 100644
index 0000000..6f9be7e
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/GerritOperator.java
@@ -0,0 +1,111 @@
+// 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;
+
+import static com.google.gerrit.k8s.operator.server.HttpServer.PORT;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import com.google.inject.name.Named;
+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.fabric8.kubernetes.client.KubernetesClient;
+import io.javaoperatorsdk.operator.Operator;
+import io.javaoperatorsdk.operator.api.reconciler.Reconciler;
+import java.util.Map;
+import java.util.Set;
+
+@Singleton
+public class GerritOperator {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+  public static final String SERVICE_NAME = "gerrit-operator";
+  public static final int SERVICE_PORT = 8080;
+
+  private final KubernetesClient client;
+  private final LifecycleManager lifecycleManager;
+
+  @SuppressWarnings("rawtypes")
+  private final Set<Reconciler> reconcilers;
+
+  private final String namespace;
+
+  private Operator operator;
+  private Service svc;
+
+  @Inject
+  @SuppressWarnings("rawtypes")
+  public GerritOperator(
+      LifecycleManager lifecycleManager,
+      KubernetesClient client,
+      Set<Reconciler> reconcilers,
+      @Named("Namespace") String namespace) {
+    this.lifecycleManager = lifecycleManager;
+    this.client = client;
+    this.reconcilers = reconcilers;
+    this.namespace = namespace;
+  }
+
+  public void start() throws Exception {
+    operator = new Operator(client);
+    for (Reconciler<?> reconciler : reconcilers) {
+      logger.atInfo().log(
+          String.format("Registering reconciler: %s", reconciler.getClass().getSimpleName()));
+      operator.register(reconciler);
+    }
+    operator.start();
+    lifecycleManager.addShutdownHook(
+        new Runnable() {
+          @Override
+          public void run() {
+            shutdown();
+          }
+        });
+    applyService();
+  }
+
+  public void shutdown() {
+    client.resource(svc).delete();
+    operator.stop();
+  }
+
+  private void applyService() {
+    ServicePort port =
+        new ServicePortBuilder()
+            .withName("http")
+            .withPort(SERVICE_PORT)
+            .withNewTargetPort(PORT)
+            .withProtocol("TCP")
+            .build();
+    svc =
+        new ServiceBuilder()
+            .withApiVersion("v1")
+            .withNewMetadata()
+            .withName(SERVICE_NAME)
+            .withNamespace(namespace)
+            .endMetadata()
+            .withNewSpec()
+            .withType("ClusterIP")
+            .withPorts(port)
+            .withSelector(Map.of("app", "gerrit-operator"))
+            .endSpec()
+            .build();
+
+    logger.atInfo().log(String.format("Applying Service for Gerrit Operator: %s", svc.toString()));
+    client.resource(svc).createOrReplace();
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/LifecycleManager.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/LifecycleManager.java
new file mode 100644
index 0000000..8e556a7
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/LifecycleManager.java
@@ -0,0 +1,39 @@
+// Copyright (C) 2023 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;
+
+import com.google.common.collect.Lists;
+import com.google.inject.Singleton;
+import java.util.ArrayList;
+import java.util.List;
+
+@Singleton
+public class LifecycleManager {
+  private List<Runnable> shutdownHooks = new ArrayList<>();
+
+  public LifecycleManager() {
+    Runtime.getRuntime().addShutdownHook(new Thread(this::executeShutdownHooks));
+  }
+
+  public void addShutdownHook(Runnable hook) {
+    shutdownHooks.add(hook);
+  }
+
+  private void executeShutdownHooks() {
+    for (Runnable hook : Lists.reverse(shutdownHooks)) {
+      hook.run();
+    }
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/Main.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/Main.java
new file mode 100644
index 0000000..8fc1428
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/Main.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2023 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;
+
+import com.google.gerrit.k8s.operator.admission.ValidationWebhookConfigs;
+import com.google.gerrit.k8s.operator.server.HttpServer;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import com.google.inject.Stage;
+
+public class Main {
+
+  public static void main(String[] args) throws Exception {
+    Injector injector = Guice.createInjector(Stage.PRODUCTION, new OperatorModule());
+    injector.getInstance(HttpServer.class).start();
+    injector.getInstance(ValidationWebhookConfigs.class).apply();
+    injector.getInstance(GerritOperator.class).start();
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/OperatorModule.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/OperatorModule.java
new file mode 100644
index 0000000..3e61528
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/OperatorModule.java
@@ -0,0 +1,57 @@
+// 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;
+
+import com.google.gerrit.k8s.operator.admission.AdmissionWebhookModule;
+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.network.GerritNetworkReconcilerProvider;
+import com.google.gerrit.k8s.operator.receiver.ReceiverReconciler;
+import com.google.gerrit.k8s.operator.server.ServerModule;
+import com.google.inject.AbstractModule;
+import com.google.inject.multibindings.Multibinder;
+import io.fabric8.kubernetes.client.Config;
+import io.fabric8.kubernetes.client.ConfigBuilder;
+import io.fabric8.kubernetes.client.KubernetesClient;
+import io.fabric8.kubernetes.client.KubernetesClientBuilder;
+import io.javaoperatorsdk.operator.api.reconciler.Reconciler;
+
+public class OperatorModule extends AbstractModule {
+  @SuppressWarnings("rawtypes")
+  @Override
+  protected void configure() {
+    install(new EnvModule());
+    install(new ServerModule());
+
+    bind(KubernetesClient.class).toInstance(getKubernetesClient());
+    bind(LifecycleManager.class);
+    bind(GerritOperator.class);
+
+    install(new AdmissionWebhookModule());
+
+    Multibinder<Reconciler> reconcilers = Multibinder.newSetBinder(binder(), Reconciler.class);
+    reconcilers.addBinding().to(GerritClusterReconciler.class);
+    reconcilers.addBinding().to(GerritReconciler.class);
+    reconcilers.addBinding().to(GitGarbageCollectionReconciler.class);
+    reconcilers.addBinding().to(ReceiverReconciler.class);
+    reconcilers.addBinding().toProvider(GerritNetworkReconcilerProvider.class);
+  }
+
+  private KubernetesClient getKubernetesClient() {
+    Config config = new ConfigBuilder().withNamespace(null).build();
+    return new KubernetesClientBuilder().withConfig(config).build();
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/admission/AdmissionWebhookModule.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/admission/AdmissionWebhookModule.java
new file mode 100644
index 0000000..d3d4841
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/admission/AdmissionWebhookModule.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2023 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.admission;
+
+import com.google.gerrit.k8s.operator.v1alpha.admission.GerritClusterValidationWebhookConfigApplier;
+import com.google.gerrit.k8s.operator.v1alpha.admission.GerritValidationWebhookConfigApplier;
+import com.google.gerrit.k8s.operator.v1alpha.admission.GitGcValidationWebhookConfigApplier;
+import com.google.inject.AbstractModule;
+import com.google.inject.assistedinject.FactoryModuleBuilder;
+
+public class AdmissionWebhookModule extends AbstractModule {
+  public void configure() {
+    install(new FactoryModuleBuilder().build(ValidationWebhookConfigApplier.Factory.class));
+
+    bind(ValidationWebhookConfigs.class);
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/admission/ValidationWebhookConfigApplier.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/admission/ValidationWebhookConfigApplier.java
new file mode 100644
index 0000000..443347e
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/admission/ValidationWebhookConfigApplier.java
@@ -0,0 +1,144 @@
+// Copyright (C) 2023 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.admission;
+
+import static com.google.gerrit.k8s.operator.GerritOperator.SERVICE_NAME;
+import static com.google.gerrit.k8s.operator.GerritOperator.SERVICE_PORT;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.k8s.operator.server.KeyStoreProvider;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
+import com.google.inject.name.Named;
+import io.fabric8.kubernetes.api.model.admissionregistration.v1.RuleWithOperations;
+import io.fabric8.kubernetes.api.model.admissionregistration.v1.RuleWithOperationsBuilder;
+import io.fabric8.kubernetes.api.model.admissionregistration.v1.ValidatingWebhook;
+import io.fabric8.kubernetes.api.model.admissionregistration.v1.ValidatingWebhookBuilder;
+import io.fabric8.kubernetes.api.model.admissionregistration.v1.ValidatingWebhookConfiguration;
+import io.fabric8.kubernetes.api.model.admissionregistration.v1.ValidatingWebhookConfigurationBuilder;
+import io.fabric8.kubernetes.client.KubernetesClient;
+import java.io.IOException;
+import java.security.KeyStoreException;
+import java.security.NoSuchAlgorithmException;
+import java.security.NoSuchProviderException;
+import java.security.cert.CertificateEncodingException;
+import java.security.cert.CertificateException;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.List;
+
+public class ValidationWebhookConfigApplier {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final KubernetesClient client;
+  private final String namespace;
+  private final KeyStoreProvider keyStoreProvider;
+  private final ValidatingWebhookConfiguration cfg;
+  private final String customResourceName;
+  private final String[] customResourceVersions;
+
+  public interface Factory {
+    ValidationWebhookConfigApplier create(
+        String customResourceName, String[] customResourceVersions);
+  }
+
+  @AssistedInject
+  ValidationWebhookConfigApplier(
+      KubernetesClient client,
+      @Named("Namespace") String namespace,
+      KeyStoreProvider keyStoreProvider,
+      @Assisted String customResourceName,
+      @Assisted String[] customResourceVersions) {
+    this.client = client;
+    this.namespace = namespace;
+    this.keyStoreProvider = keyStoreProvider;
+    this.customResourceName = customResourceName;
+    this.customResourceVersions = customResourceVersions;
+
+    this.cfg = build();
+  }
+
+  public List<RuleWithOperations> rules(String version) {
+    return List.of(
+        new RuleWithOperationsBuilder()
+            .withApiGroups("gerritoperator.google.com")
+            .withApiVersions(version)
+            .withOperations("CREATE", "UPDATE")
+            .withResources(customResourceName)
+            .withScope("*")
+            .build());
+  }
+
+  public List<ValidatingWebhook> webhooks()
+      throws CertificateEncodingException, KeyStoreException, NoSuchAlgorithmException,
+          CertificateException, IOException {
+    List<ValidatingWebhook> webhooks = new ArrayList<>();
+    for (String version : customResourceVersions) {
+      webhooks.add(
+          new ValidatingWebhookBuilder()
+              .withName(customResourceName.toLowerCase() + "." + version + ".validator.google.com")
+              .withAdmissionReviewVersions("v1", "v1beta1")
+              .withNewClientConfig()
+              .withCaBundle(caBundle())
+              .withNewService()
+              .withName(SERVICE_NAME)
+              .withNamespace(namespace)
+              .withPath(
+                  String.format("/admission/%s/%s", version, customResourceName).toLowerCase())
+              .withPort(SERVICE_PORT)
+              .endService()
+              .endClientConfig()
+              .withFailurePolicy("Fail")
+              .withMatchPolicy("Equivalent")
+              .withRules(rules(version))
+              .withTimeoutSeconds(10)
+              .withSideEffects("None")
+              .build());
+    }
+    return webhooks;
+  }
+
+  private String caBundle()
+      throws CertificateEncodingException, KeyStoreException, NoSuchAlgorithmException,
+          CertificateException, IOException {
+    return Base64.getEncoder().encodeToString(keyStoreProvider.getCertificate().getBytes());
+  }
+
+  public ValidatingWebhookConfiguration build() {
+    try {
+      return new ValidatingWebhookConfigurationBuilder()
+          .withNewMetadata()
+          .withName(customResourceName.toLowerCase())
+          .endMetadata()
+          .withWebhooks(webhooks())
+          .build();
+    } catch (CertificateException | IOException | KeyStoreException | NoSuchAlgorithmException e) {
+      throw new RuntimeException(
+          "Failed to deploy ValidationWebhookConfiguration " + customResourceName, e);
+    }
+  }
+
+  public void apply()
+      throws KeyStoreException, NoSuchProviderException, IOException, NoSuchAlgorithmException,
+          CertificateException {
+    logger.atInfo().log("Applying webhook config %s", cfg);
+    client.resource(cfg).createOrReplace();
+  }
+
+  public void delete() {
+    logger.atInfo().log("Deleting webhook config %s", cfg);
+    client.resource(cfg).delete();
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/admission/ValidationWebhookConfigs.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/admission/ValidationWebhookConfigs.java
new file mode 100644
index 0000000..901d15f
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/admission/ValidationWebhookConfigs.java
@@ -0,0 +1,60 @@
+// Copyright (C) 2023 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.admission;
+
+import static com.google.gerrit.k8s.operator.Constants.CUSTOM_RESOURCES;
+import static com.google.gerrit.k8s.operator.Constants.VERSIONS;
+
+import com.google.gerrit.k8s.operator.LifecycleManager;
+import com.google.inject.Inject;
+import java.util.ArrayList;
+import java.util.List;
+
+public class ValidationWebhookConfigs {
+
+  private final List<ValidationWebhookConfigApplier> configAppliers;
+
+  @Inject
+  public ValidationWebhookConfigs(
+      LifecycleManager lifecycleManager,
+      ValidationWebhookConfigApplier.Factory configApplierFactory) {
+    this.configAppliers = new ArrayList<>();
+
+    for (String customResourceName : CUSTOM_RESOURCES) {
+      this.configAppliers.add(configApplierFactory.create(customResourceName, VERSIONS));
+    }
+
+    lifecycleManager.addShutdownHook(
+        new Runnable() {
+
+          @Override
+          public void run() {
+            delete();
+          }
+        });
+  }
+
+  public void apply() throws Exception {
+    for (ValidationWebhookConfigApplier applier : configAppliers) {
+      applier.apply();
+    }
+  }
+
+  public void delete() {
+    for (ValidationWebhookConfigApplier applier : configAppliers) {
+      applier.delete();
+    }
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/cluster/GerritClusterReconciler.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/cluster/GerritClusterReconciler.java
new file mode 100644
index 0000000..c00d51e
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/cluster/GerritClusterReconciler.java
@@ -0,0 +1,149 @@
+// 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 static com.google.gerrit.k8s.operator.cluster.GerritClusterReconciler.CLUSTER_MANAGED_GERRIT_EVENT_SOURCE;
+import static com.google.gerrit.k8s.operator.cluster.GerritClusterReconciler.CLUSTER_MANAGED_GERRIT_NETWORK_EVENT_SOURCE;
+import static com.google.gerrit.k8s.operator.cluster.GerritClusterReconciler.CLUSTER_MANAGED_RECEIVER_EVENT_SOURCE;
+import static com.google.gerrit.k8s.operator.cluster.GerritClusterReconciler.CM_EVENT_SOURCE;
+import static com.google.gerrit.k8s.operator.cluster.GerritClusterReconciler.PVC_EVENT_SOURCE;
+
+import com.google.gerrit.k8s.operator.cluster.dependent.ClusterManagedGerrit;
+import com.google.gerrit.k8s.operator.cluster.dependent.ClusterManagedGerritCondition;
+import com.google.gerrit.k8s.operator.cluster.dependent.ClusterManagedGerritNetwork;
+import com.google.gerrit.k8s.operator.cluster.dependent.ClusterManagedGerritNetworkCondition;
+import com.google.gerrit.k8s.operator.cluster.dependent.ClusterManagedReceiver;
+import com.google.gerrit.k8s.operator.cluster.dependent.ClusterManagedReceiverCondition;
+import com.google.gerrit.k8s.operator.cluster.dependent.NfsIdmapdConfigMap;
+import com.google.gerrit.k8s.operator.cluster.dependent.NfsWorkaroundCondition;
+import com.google.gerrit.k8s.operator.cluster.dependent.SharedPVC;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.cluster.GerritCluster;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.cluster.GerritClusterStatus;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.gerrit.Gerrit;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.gerrit.GerritTemplate;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.network.GerritNetwork;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.receiver.Receiver;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.receiver.ReceiverTemplate;
+import com.google.inject.Singleton;
+import io.fabric8.kubernetes.api.model.ConfigMap;
+import io.fabric8.kubernetes.api.model.PersistentVolumeClaim;
+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.source.EventSource;
+import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+@Singleton
+@ControllerConfiguration(
+    dependents = {
+      @Dependent(
+          name = "shared-pvc",
+          type = SharedPVC.class,
+          useEventSourceWithName = PVC_EVENT_SOURCE),
+      @Dependent(
+          type = NfsIdmapdConfigMap.class,
+          reconcilePrecondition = NfsWorkaroundCondition.class,
+          useEventSourceWithName = CM_EVENT_SOURCE),
+      @Dependent(
+          name = "gerrits",
+          type = ClusterManagedGerrit.class,
+          reconcilePrecondition = ClusterManagedGerritCondition.class,
+          useEventSourceWithName = CLUSTER_MANAGED_GERRIT_EVENT_SOURCE),
+      @Dependent(
+          name = "receiver",
+          type = ClusterManagedReceiver.class,
+          reconcilePrecondition = ClusterManagedReceiverCondition.class,
+          useEventSourceWithName = CLUSTER_MANAGED_RECEIVER_EVENT_SOURCE),
+      @Dependent(
+          type = ClusterManagedGerritNetwork.class,
+          reconcilePrecondition = ClusterManagedGerritNetworkCondition.class,
+          useEventSourceWithName = CLUSTER_MANAGED_GERRIT_NETWORK_EVENT_SOURCE),
+    })
+public class GerritClusterReconciler
+    implements Reconciler<GerritCluster>, EventSourceInitializer<GerritCluster> {
+  public static final String CM_EVENT_SOURCE = "cm-event-source";
+  public static final String PVC_EVENT_SOURCE = "pvc-event-source";
+  public static final String CLUSTER_MANAGED_GERRIT_EVENT_SOURCE = "cluster-managed-gerrit";
+  public static final String CLUSTER_MANAGED_RECEIVER_EVENT_SOURCE = "cluster-managed-receiver";
+  public static final String CLUSTER_MANAGED_GERRIT_NETWORK_EVENT_SOURCE =
+      "cluster-managed-gerrit-network";
+
+  @Override
+  public Map<String, EventSource> prepareEventSources(EventSourceContext<GerritCluster> context) {
+    InformerEventSource<ConfigMap, GerritCluster> cmEventSource =
+        new InformerEventSource<>(
+            InformerConfiguration.from(ConfigMap.class, context).build(), context);
+
+    InformerEventSource<PersistentVolumeClaim, GerritCluster> pvcEventSource =
+        new InformerEventSource<>(
+            InformerConfiguration.from(PersistentVolumeClaim.class, context).build(), context);
+
+    InformerEventSource<Gerrit, GerritCluster> clusterManagedGerritEventSource =
+        new InformerEventSource<>(
+            InformerConfiguration.from(Gerrit.class, context).build(), context);
+
+    InformerEventSource<Receiver, GerritCluster> clusterManagedReceiverEventSource =
+        new InformerEventSource<>(
+            InformerConfiguration.from(Receiver.class, context).build(), context);
+
+    InformerEventSource<GerritNetwork, GerritCluster> clusterManagedGerritNetworkEventSource =
+        new InformerEventSource<>(
+            InformerConfiguration.from(GerritNetwork.class, context).build(), context);
+
+    Map<String, EventSource> eventSources = new HashMap<>();
+    eventSources.put(CM_EVENT_SOURCE, cmEventSource);
+    eventSources.put(PVC_EVENT_SOURCE, pvcEventSource);
+    eventSources.put(CLUSTER_MANAGED_GERRIT_EVENT_SOURCE, clusterManagedGerritEventSource);
+    eventSources.put(CLUSTER_MANAGED_RECEIVER_EVENT_SOURCE, clusterManagedReceiverEventSource);
+    eventSources.put(
+        CLUSTER_MANAGED_GERRIT_NETWORK_EVENT_SOURCE, clusterManagedGerritNetworkEventSource);
+    return eventSources;
+  }
+
+  @Override
+  public UpdateControl<GerritCluster> reconcile(
+      GerritCluster gerritCluster, Context<GerritCluster> context) {
+    List<GerritTemplate> managedGerrits = gerritCluster.getSpec().getGerrits();
+    Map<String, List<String>> members = new HashMap<>();
+    members.put(
+        "gerrit",
+        managedGerrits.stream().map(g -> g.getMetadata().getName()).collect(Collectors.toList()));
+    ReceiverTemplate managedReceiver = gerritCluster.getSpec().getReceiver();
+    if (managedReceiver != null) {
+      members.put("receiver", List.of(managedReceiver.getMetadata().getName()));
+    }
+    return UpdateControl.patchStatus(updateStatus(gerritCluster, members));
+  }
+
+  private GerritCluster updateStatus(
+      GerritCluster gerritCluster, Map<String, List<String>> members) {
+    GerritClusterStatus status = gerritCluster.getStatus();
+    if (status == null) {
+      status = new GerritClusterStatus();
+    }
+    status.setMembers(members);
+    gerritCluster.setStatus(status);
+    return gerritCluster;
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/cluster/dependent/ClusterManagedGerrit.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/cluster/dependent/ClusterManagedGerrit.java
new file mode 100644
index 0000000..8de0b3e
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/cluster/dependent/ClusterManagedGerrit.java
@@ -0,0 +1,66 @@
+// Copyright (C) 2023 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.dependent;
+
+import com.google.gerrit.k8s.operator.v1alpha.api.model.cluster.GerritCluster;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.gerrit.Gerrit;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.gerrit.GerritTemplate;
+import io.javaoperatorsdk.operator.api.reconciler.Context;
+import io.javaoperatorsdk.operator.api.reconciler.dependent.Deleter;
+import io.javaoperatorsdk.operator.api.reconciler.dependent.GarbageCollected;
+import io.javaoperatorsdk.operator.processing.dependent.BulkDependentResource;
+import io.javaoperatorsdk.operator.processing.dependent.Creator;
+import io.javaoperatorsdk.operator.processing.dependent.Updater;
+import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependentResource;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+
+public class ClusterManagedGerrit extends KubernetesDependentResource<Gerrit, GerritCluster>
+    implements Creator<Gerrit, GerritCluster>,
+        Updater<Gerrit, GerritCluster>,
+        Deleter<GerritCluster>,
+        BulkDependentResource<Gerrit, GerritCluster>,
+        GarbageCollected<GerritCluster> {
+
+  public ClusterManagedGerrit() {
+    super(Gerrit.class);
+  }
+
+  @Override
+  public Map<String, Gerrit> desiredResources(
+      GerritCluster gerritCluster, Context<GerritCluster> context) {
+    Map<String, Gerrit> gerrits = new HashMap<>();
+    for (GerritTemplate template : gerritCluster.getSpec().getGerrits()) {
+      gerrits.put(template.getMetadata().getName(), desired(gerritCluster, template));
+    }
+    return gerrits;
+  }
+
+  private Gerrit desired(GerritCluster gerritCluster, GerritTemplate template) {
+    return template.toGerrit(gerritCluster);
+  }
+
+  @Override
+  public Map<String, Gerrit> getSecondaryResources(
+      GerritCluster primary, Context<GerritCluster> context) {
+    Set<Gerrit> gerrits = context.getSecondaryResources(Gerrit.class);
+    Map<String, Gerrit> result = new HashMap<>(gerrits.size());
+    for (Gerrit gerrit : gerrits) {
+      result.put(gerrit.getMetadata().getName(), gerrit);
+    }
+    return result;
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/cluster/dependent/ClusterManagedGerritCondition.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/cluster/dependent/ClusterManagedGerritCondition.java
new file mode 100644
index 0000000..7455aca
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/cluster/dependent/ClusterManagedGerritCondition.java
@@ -0,0 +1,32 @@
+// Copyright (C) 2023 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.dependent;
+
+import com.google.gerrit.k8s.operator.v1alpha.api.model.cluster.GerritCluster;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.gerrit.Gerrit;
+import io.javaoperatorsdk.operator.api.reconciler.Context;
+import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource;
+import io.javaoperatorsdk.operator.processing.dependent.workflow.Condition;
+
+public class ClusterManagedGerritCondition implements Condition<Gerrit, GerritCluster> {
+
+  @Override
+  public boolean isMet(
+      DependentResource<Gerrit, GerritCluster> dependentResource,
+      GerritCluster gerritCluster,
+      Context<GerritCluster> context) {
+    return !gerritCluster.getSpec().getGerrits().isEmpty();
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/cluster/dependent/ClusterManagedGerritNetwork.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/cluster/dependent/ClusterManagedGerritNetwork.java
new file mode 100644
index 0000000..232c791
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/cluster/dependent/ClusterManagedGerritNetwork.java
@@ -0,0 +1,81 @@
+// Copyright (C) 2023 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.dependent;
+
+import com.google.gerrit.k8s.operator.v1alpha.api.model.cluster.GerritCluster;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.gerrit.GerritTemplate;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.gerrit.GerritTemplateSpec.GerritMode;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.network.GerritNetwork;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.network.GerritNetworkSpec;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.network.NetworkMember;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.network.NetworkMemberWithSsh;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.receiver.ReceiverTemplate;
+import io.fabric8.kubernetes.api.model.ObjectMetaBuilder;
+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.Optional;
+
+@KubernetesDependent
+public class ClusterManagedGerritNetwork
+    extends CRUDKubernetesDependentResource<GerritNetwork, GerritCluster> {
+  public static final String NAME_SUFFIX = "gerrit-network";
+
+  public ClusterManagedGerritNetwork() {
+    super(GerritNetwork.class);
+  }
+
+  @Override
+  public GerritNetwork desired(GerritCluster gerritCluster, Context<GerritCluster> context) {
+    GerritNetwork gerritNetwork = new GerritNetwork();
+    gerritNetwork.setMetadata(
+        new ObjectMetaBuilder()
+            .withName(gerritCluster.getDependentResourceName(NAME_SUFFIX))
+            .withNamespace(gerritCluster.getMetadata().getNamespace())
+            .build());
+    GerritNetworkSpec gerritNetworkSpec = new GerritNetworkSpec();
+
+    Optional<GerritTemplate> optionalPrimaryGerrit =
+        gerritCluster.getSpec().getGerrits().stream()
+            .filter(g -> g.getSpec().getMode().equals(GerritMode.PRIMARY))
+            .findFirst();
+    if (optionalPrimaryGerrit.isPresent()) {
+      GerritTemplate primaryGerrit = optionalPrimaryGerrit.get();
+      gerritNetworkSpec.setPrimaryGerrit(
+          new NetworkMemberWithSsh(
+              primaryGerrit.getMetadata().getName(), primaryGerrit.getSpec().getService()));
+    }
+
+    Optional<GerritTemplate> optionalGerritReplica =
+        gerritCluster.getSpec().getGerrits().stream()
+            .filter(g -> g.getSpec().getMode().equals(GerritMode.REPLICA))
+            .findFirst();
+    if (optionalGerritReplica.isPresent()) {
+      GerritTemplate gerritReplica = optionalGerritReplica.get();
+      gerritNetworkSpec.setGerritReplica(
+          new NetworkMemberWithSsh(
+              gerritReplica.getMetadata().getName(), gerritReplica.getSpec().getService()));
+    }
+
+    ReceiverTemplate receiver = gerritCluster.getSpec().getReceiver();
+    if (receiver != null) {
+      gerritNetworkSpec.setReceiver(
+          new NetworkMember(receiver.getMetadata().getName(), receiver.getSpec().getService()));
+    }
+    gerritNetworkSpec.setIngress(gerritCluster.getSpec().getIngress());
+    gerritNetwork.setSpec(gerritNetworkSpec);
+    return gerritNetwork;
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/cluster/dependent/ClusterManagedGerritNetworkCondition.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/cluster/dependent/ClusterManagedGerritNetworkCondition.java
new file mode 100644
index 0000000..a5b9244
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/cluster/dependent/ClusterManagedGerritNetworkCondition.java
@@ -0,0 +1,33 @@
+// Copyright (C) 2023 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.dependent;
+
+import com.google.gerrit.k8s.operator.v1alpha.api.model.cluster.GerritCluster;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.network.GerritNetwork;
+import io.javaoperatorsdk.operator.api.reconciler.Context;
+import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource;
+import io.javaoperatorsdk.operator.processing.dependent.workflow.Condition;
+
+public class ClusterManagedGerritNetworkCondition
+    implements Condition<GerritNetwork, GerritCluster> {
+
+  @Override
+  public boolean isMet(
+      DependentResource<GerritNetwork, GerritCluster> dependentResource,
+      GerritCluster gerritCluster,
+      Context<GerritCluster> context) {
+    return gerritCluster.getSpec().getIngress().isEnabled();
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/cluster/dependent/ClusterManagedReceiver.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/cluster/dependent/ClusterManagedReceiver.java
new file mode 100644
index 0000000..62618a2
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/cluster/dependent/ClusterManagedReceiver.java
@@ -0,0 +1,33 @@
+// Copyright (C) 2023 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.dependent;
+
+import com.google.gerrit.k8s.operator.v1alpha.api.model.cluster.GerritCluster;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.receiver.Receiver;
+import io.javaoperatorsdk.operator.api.reconciler.Context;
+import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource;
+
+public class ClusterManagedReceiver
+    extends CRUDKubernetesDependentResource<Receiver, GerritCluster> {
+
+  public ClusterManagedReceiver() {
+    super(Receiver.class);
+  }
+
+  @Override
+  public Receiver desired(GerritCluster gerritCluster, Context<GerritCluster> context) {
+    return gerritCluster.getSpec().getReceiver().toReceiver(gerritCluster);
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/cluster/dependent/ClusterManagedReceiverCondition.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/cluster/dependent/ClusterManagedReceiverCondition.java
new file mode 100644
index 0000000..aa27446
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/cluster/dependent/ClusterManagedReceiverCondition.java
@@ -0,0 +1,32 @@
+// 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.dependent;
+
+import com.google.gerrit.k8s.operator.v1alpha.api.model.cluster.GerritCluster;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.receiver.Receiver;
+import io.javaoperatorsdk.operator.api.reconciler.Context;
+import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource;
+import io.javaoperatorsdk.operator.processing.dependent.workflow.Condition;
+
+public class ClusterManagedReceiverCondition implements Condition<Receiver, GerritCluster> {
+
+  @Override
+  public boolean isMet(
+      DependentResource<Receiver, GerritCluster> dependentResource,
+      GerritCluster gerritCluster,
+      Context<GerritCluster> context) {
+    return gerritCluster.getSpec().getReceiver() != null;
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/cluster/dependent/NfsIdmapdConfigMap.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/cluster/dependent/NfsIdmapdConfigMap.java
new file mode 100644
index 0000000..623e801
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/cluster/dependent/NfsIdmapdConfigMap.java
@@ -0,0 +1,52 @@
+// 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.dependent;
+
+import com.google.gerrit.k8s.operator.v1alpha.api.model.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(resourceDiscriminator = NfsIdmapdConfigMapDiscriminator.class)
+public class NfsIdmapdConfigMap extends CRUDKubernetesDependentResource<ConfigMap, GerritCluster> {
+  public static final String NFS_IDMAPD_CM_NAME = "nfs-idmapd-config";
+
+  public NfsIdmapdConfigMap() {
+    super(ConfigMap.class);
+  }
+
+  @Override
+  protected ConfigMap desired(GerritCluster gerritCluster, Context<GerritCluster> context) {
+    return new ConfigMapBuilder()
+        .withNewMetadata()
+        .withName(NFS_IDMAPD_CM_NAME)
+        .withNamespace(gerritCluster.getMetadata().getNamespace())
+        .withLabels(gerritCluster.getLabels(NFS_IDMAPD_CM_NAME, this.getClass().getSimpleName()))
+        .endMetadata()
+        .withData(
+            Map.of(
+                "idmapd.conf",
+                gerritCluster
+                    .getSpec()
+                    .getStorage()
+                    .getStorageClasses()
+                    .getNfsWorkaround()
+                    .getIdmapdConfig()))
+        .build();
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/cluster/dependent/NfsIdmapdConfigMapDiscriminator.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/cluster/dependent/NfsIdmapdConfigMapDiscriminator.java
new file mode 100644
index 0000000..17dfea7
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/cluster/dependent/NfsIdmapdConfigMapDiscriminator.java
@@ -0,0 +1,42 @@
+// Copyright (C) 2023 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.dependent;
+
+import static com.google.gerrit.k8s.operator.cluster.GerritClusterReconciler.CM_EVENT_SOURCE;
+
+import com.google.gerrit.k8s.operator.v1alpha.api.model.cluster.GerritCluster;
+import io.fabric8.kubernetes.api.model.ConfigMap;
+import io.javaoperatorsdk.operator.api.reconciler.Context;
+import io.javaoperatorsdk.operator.api.reconciler.ResourceDiscriminator;
+import io.javaoperatorsdk.operator.processing.event.ResourceID;
+import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource;
+import java.util.Optional;
+
+public class NfsIdmapdConfigMapDiscriminator
+    implements ResourceDiscriminator<ConfigMap, GerritCluster> {
+  @Override
+  public Optional<ConfigMap> distinguish(
+      Class<ConfigMap> resource, GerritCluster primary, Context<GerritCluster> context) {
+    InformerEventSource<ConfigMap, GerritCluster> ies =
+        (InformerEventSource<ConfigMap, GerritCluster>)
+            context
+                .eventSourceRetriever()
+                .getResourceEventSourceFor(ConfigMap.class, CM_EVENT_SOURCE);
+
+    return ies.get(
+        new ResourceID(
+            NfsIdmapdConfigMap.NFS_IDMAPD_CM_NAME, primary.getMetadata().getNamespace()));
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/cluster/dependent/NfsWorkaroundCondition.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/cluster/dependent/NfsWorkaroundCondition.java
new file mode 100644
index 0000000..a0cccc0
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/cluster/dependent/NfsWorkaroundCondition.java
@@ -0,0 +1,34 @@
+// 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.dependent;
+
+import com.google.gerrit.k8s.operator.v1alpha.api.model.cluster.GerritCluster;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.shared.NfsWorkaroundConfig;
+import io.fabric8.kubernetes.api.model.ConfigMap;
+import io.javaoperatorsdk.operator.api.reconciler.Context;
+import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource;
+import io.javaoperatorsdk.operator.processing.dependent.workflow.Condition;
+
+public class NfsWorkaroundCondition implements Condition<ConfigMap, GerritCluster> {
+  @Override
+  public boolean isMet(
+      DependentResource<ConfigMap, GerritCluster> dependentResource,
+      GerritCluster gerritCluster,
+      Context<GerritCluster> context) {
+    NfsWorkaroundConfig cfg =
+        gerritCluster.getSpec().getStorage().getStorageClasses().getNfsWorkaround();
+    return cfg.isEnabled() && cfg.getIdmapdConfig() != null;
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/cluster/dependent/SharedPVC.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/cluster/dependent/SharedPVC.java
new file mode 100644
index 0000000..099afc6
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/cluster/dependent/SharedPVC.java
@@ -0,0 +1,54 @@
+// 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.dependent;
+
+import com.google.gerrit.k8s.operator.util.CRUDKubernetesDependentPVCResource;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.cluster.GerritCluster;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.shared.GerritStorageConfig;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.shared.SharedStorage;
+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.KubernetesDependent;
+import java.util.Map;
+
+@KubernetesDependent(resourceDiscriminator = SharedPVCDiscriminator.class)
+public class SharedPVC extends CRUDKubernetesDependentPVCResource<GerritCluster> {
+
+  public static final String SHARED_PVC_NAME = "shared-pvc";
+
+  @Override
+  protected PersistentVolumeClaim desiredPVC(
+      GerritCluster gerritCluster, Context<GerritCluster> context) {
+    GerritStorageConfig storageConfig = gerritCluster.getSpec().getStorage();
+    SharedStorage sharedStorage = storageConfig.getSharedStorage();
+    return new PersistentVolumeClaimBuilder()
+        .withNewMetadata()
+        .withName(SHARED_PVC_NAME)
+        .withNamespace(gerritCluster.getMetadata().getNamespace())
+        .withLabels(gerritCluster.getLabels("shared-storage", this.getClass().getSimpleName()))
+        .endMetadata()
+        .withNewSpec()
+        .withAccessModes("ReadWriteMany")
+        .withNewResources()
+        .withRequests(Map.of("storage", sharedStorage.getSize()))
+        .endResources()
+        .withStorageClassName(storageConfig.getStorageClasses().getReadWriteMany())
+        .withSelector(sharedStorage.getSelector())
+        .withVolumeName(sharedStorage.getVolumeName())
+        .endSpec()
+        .build();
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/cluster/dependent/SharedPVCDiscriminator.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/cluster/dependent/SharedPVCDiscriminator.java
new file mode 100644
index 0000000..52fe941
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/cluster/dependent/SharedPVCDiscriminator.java
@@ -0,0 +1,42 @@
+// 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.dependent;
+
+import static com.google.gerrit.k8s.operator.cluster.GerritClusterReconciler.PVC_EVENT_SOURCE;
+
+import com.google.gerrit.k8s.operator.v1alpha.api.model.cluster.GerritCluster;
+import io.fabric8.kubernetes.api.model.PersistentVolumeClaim;
+import io.javaoperatorsdk.operator.api.reconciler.Context;
+import io.javaoperatorsdk.operator.api.reconciler.ResourceDiscriminator;
+import io.javaoperatorsdk.operator.processing.event.ResourceID;
+import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource;
+import java.util.Optional;
+
+public class SharedPVCDiscriminator
+    implements ResourceDiscriminator<PersistentVolumeClaim, GerritCluster> {
+  @Override
+  public Optional<PersistentVolumeClaim> distinguish(
+      Class<PersistentVolumeClaim> resource,
+      GerritCluster primary,
+      Context<GerritCluster> context) {
+    InformerEventSource<PersistentVolumeClaim, GerritCluster> ies =
+        (InformerEventSource<PersistentVolumeClaim, GerritCluster>)
+            context
+                .eventSourceRetriever()
+                .getResourceEventSourceFor(PersistentVolumeClaim.class, PVC_EVENT_SOURCE);
+
+    return ies.get(new ResourceID(SharedPVC.SHARED_PVC_NAME, primary.getMetadata().getNamespace()));
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/GerritReconciler.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/GerritReconciler.java
new file mode 100644
index 0000000..04d0a2f
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/GerritReconciler.java
@@ -0,0 +1,146 @@
+// 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.GerritReconciler.CONFIG_MAP_EVENT_SOURCE;
+import static com.google.gerrit.k8s.operator.gerrit.dependent.GerritSecret.CONTEXT_SECRET_VERSION_KEY;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.k8s.operator.gerrit.dependent.GerritConfigMap;
+import com.google.gerrit.k8s.operator.gerrit.dependent.GerritInitConfigMap;
+import com.google.gerrit.k8s.operator.gerrit.dependent.GerritSecret;
+import com.google.gerrit.k8s.operator.gerrit.dependent.GerritService;
+import com.google.gerrit.k8s.operator.gerrit.dependent.GerritStatefulSet;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.gerrit.Gerrit;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.gerrit.GerritStatus;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import io.fabric8.kubernetes.api.model.ConfigMap;
+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.dependent.workflow.WorkflowReconcileResult;
+import io.javaoperatorsdk.operator.processing.event.source.EventSource;
+import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+
+@Singleton
+@ControllerConfiguration(
+    dependents = {
+      @Dependent(name = "gerrit-secret", type = GerritSecret.class),
+      @Dependent(
+          name = "gerrit-configmap",
+          type = GerritConfigMap.class,
+          useEventSourceWithName = CONFIG_MAP_EVENT_SOURCE),
+      @Dependent(
+          name = "gerrit-init-configmap",
+          type = GerritInitConfigMap.class,
+          useEventSourceWithName = CONFIG_MAP_EVENT_SOURCE),
+      @Dependent(
+          name = "gerrit-statefulset",
+          type = GerritStatefulSet.class,
+          dependsOn = {"gerrit-configmap", "gerrit-init-configmap"}),
+      @Dependent(
+          name = "gerrit-service",
+          type = GerritService.class,
+          dependsOn = {"gerrit-statefulset"})
+    })
+public class GerritReconciler implements Reconciler<Gerrit>, EventSourceInitializer<Gerrit> {
+  public static final String CONFIG_MAP_EVENT_SOURCE = "configmap-event-source";
+
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final KubernetesClient client;
+
+  @Inject
+  public GerritReconciler(KubernetesClient client) {
+    this.client = client;
+  }
+
+  @Override
+  public Map<String, EventSource> prepareEventSources(EventSourceContext<Gerrit> context) {
+    InformerEventSource<ConfigMap, Gerrit> configmapEventSource =
+        new InformerEventSource<>(
+            InformerConfiguration.from(ConfigMap.class, context).build(), context);
+
+    Map<String, EventSource> eventSources = new HashMap<>();
+    eventSources.put(CONFIG_MAP_EVENT_SOURCE, configmapEventSource);
+    return eventSources;
+  }
+
+  @Override
+  public UpdateControl<Gerrit> reconcile(Gerrit gerrit, Context<Gerrit> context) throws Exception {
+    return UpdateControl.patchStatus(updateStatus(gerrit, context));
+  }
+
+  private Gerrit updateStatus(Gerrit gerrit, Context<Gerrit> context) {
+    GerritStatus status = gerrit.getStatus();
+    if (status == null) {
+      status = new GerritStatus();
+    }
+    Optional<WorkflowReconcileResult> result =
+        context.managedDependentResourceContext().getWorkflowReconcileResult();
+    if (result.isPresent()) {
+      status.setReady(result.get().allDependentResourcesReady());
+    } else {
+      status.setReady(false);
+    }
+
+    Map<String, String> cmVersions = new HashMap<>();
+
+    cmVersions.put(
+        GerritConfigMap.getName(gerrit),
+        client
+            .configMaps()
+            .inNamespace(gerrit.getMetadata().getNamespace())
+            .withName(GerritConfigMap.getName(gerrit))
+            .get()
+            .getMetadata()
+            .getResourceVersion());
+
+    cmVersions.put(
+        GerritInitConfigMap.getName(gerrit),
+        client
+            .configMaps()
+            .inNamespace(gerrit.getMetadata().getNamespace())
+            .withName(GerritInitConfigMap.getName(gerrit))
+            .get()
+            .getMetadata()
+            .getResourceVersion());
+
+    logger.atFine().log("Adding ConfigMap versions: %s", cmVersions);
+    status.setAppliedConfigMapVersions(cmVersions);
+
+    Map<String, String> secretVersions = new HashMap<>();
+    Optional<String> gerritSecret =
+        context.managedDependentResourceContext().get(CONTEXT_SECRET_VERSION_KEY, String.class);
+    if (gerritSecret.isPresent()) {
+      secretVersions.put(gerrit.getSpec().getSecretRef(), gerritSecret.get());
+    }
+    logger.atFine().log("Adding Secret versions: %s", secretVersions);
+    status.setAppliedSecretVersions(secretVersions);
+
+    gerrit.setStatus(status);
+    return gerrit;
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/config/ConfigBuilder.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/config/ConfigBuilder.java
new file mode 100644
index 0000000..ca58e94
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/config/ConfigBuilder.java
@@ -0,0 +1,91 @@
+// Copyright (C) 2023 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.config;
+
+import com.google.common.collect.ImmutableList;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+
+public abstract class ConfigBuilder {
+
+  private final ImmutableList<RequiredOption<?>> requiredOptions;
+  private final Config config;
+
+  ConfigBuilder(Config baseConfig, ImmutableList<RequiredOption<?>> requiredOptions) {
+    this.config = baseConfig;
+    this.requiredOptions = requiredOptions;
+  }
+
+  protected ConfigBuilder(String baseConfig, ImmutableList<RequiredOption<?>> requiredOptions) {
+    this.config = parseConfig(baseConfig);
+    this.requiredOptions = requiredOptions;
+  }
+
+  public Config build() {
+    ConfigValidator configValidator = new ConfigValidator(requiredOptions);
+    try {
+      configValidator.check(config);
+    } catch (InvalidGerritConfigException e) {
+      throw new IllegalStateException(e);
+    }
+    setRequiredOptions();
+    return config;
+  }
+
+  public void validate() throws InvalidGerritConfigException {
+    new ConfigValidator(requiredOptions).check(config);
+  }
+
+  public List<RequiredOption<?>> getRequiredOptions() {
+    return this.requiredOptions;
+  }
+
+  protected Config parseConfig(String text) {
+    Config cfg = new Config();
+    try {
+      cfg.fromText(text);
+    } catch (ConfigInvalidException e) {
+      throw new IllegalStateException("Invalid configuration: " + text, e);
+    }
+    return cfg;
+  }
+
+  @SuppressWarnings("unchecked")
+  private void setRequiredOptions() {
+    for (RequiredOption<?> opt : requiredOptions) {
+      if (opt.getExpected() instanceof String) {
+        config.setString(
+            opt.getSection(), opt.getSubSection(), opt.getKey(), (String) opt.getExpected());
+      } else if (opt.getExpected() instanceof Boolean) {
+        config.setBoolean(
+            opt.getSection(), opt.getSubSection(), opt.getKey(), (Boolean) opt.getExpected());
+      } else if (opt.getExpected() instanceof Set) {
+        List<String> values =
+            new ArrayList<String>(
+                Arrays.asList(
+                    config.getStringList(opt.getSection(), opt.getSubSection(), opt.getKey())));
+        List<String> expectedSet = new ArrayList<String>();
+        expectedSet.addAll((Set<String>) opt.getExpected());
+        expectedSet.removeAll(values);
+        values.addAll(expectedSet);
+        config.setStringList(opt.getSection(), opt.getSubSection(), opt.getKey(), values);
+      }
+    }
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/config/ConfigValidator.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/config/ConfigValidator.java
new file mode 100644
index 0000000..bc952a1
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/config/ConfigValidator.java
@@ -0,0 +1,56 @@
+// 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.config;
+
+import java.util.List;
+import java.util.Set;
+import org.eclipse.jgit.lib.Config;
+
+public class ConfigValidator {
+  private final List<RequiredOption<?>> requiredOptions;
+
+  public ConfigValidator(List<RequiredOption<?>> requiredOptions) {
+    this.requiredOptions = requiredOptions;
+  }
+
+  public void check(Config cfg) throws InvalidGerritConfigException {
+    for (RequiredOption<?> opt : requiredOptions) {
+      checkOption(cfg, opt);
+    }
+  }
+
+  private void checkOption(Config cfg, RequiredOption<?> opt) throws InvalidGerritConfigException {
+    if (!optionExists(cfg, opt)) {
+      return;
+    }
+    if (opt.getExpected() instanceof Set) {
+      return;
+    } else {
+      String value = cfg.getString(opt.getSection(), opt.getSubSection(), opt.getKey());
+      if (isExpectedValue(value, opt)) {
+        return;
+      }
+      throw new InvalidGerritConfigException(value, opt);
+    }
+  }
+
+  private boolean optionExists(Config cfg, RequiredOption<?> opt) {
+    return cfg.getNames(opt.getSection(), opt.getSubSection()).contains(opt.getKey());
+  }
+
+  private boolean isExpectedValue(String value, RequiredOption<?> opt) {
+    return value.equals(opt.getExpected().toString());
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/config/InvalidGerritConfigException.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/config/InvalidGerritConfigException.java
new file mode 100644
index 0000000..6ab14bd
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/config/InvalidGerritConfigException.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2023 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.config;
+
+public class InvalidGerritConfigException extends Exception {
+  private static final long serialVersionUID = 1L;
+
+  public InvalidGerritConfigException(String value, RequiredOption<?> opt) {
+    super(
+        String.format(
+            "Option %s.%s.%s set to unsupported value %s. Expected %s.",
+            opt.getSection(), opt.getSubSection(), opt.getKey(), value, opt.getExpected()));
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/config/RequiredOption.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/config/RequiredOption.java
new file mode 100644
index 0000000..9f4d22c
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/config/RequiredOption.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.config;
+
+public class RequiredOption<T> {
+  private final String section;
+  private final String subSection;
+  private final String key;
+  private final T expected;
+
+  public RequiredOption(String section, String subSection, String key, T expected) {
+    this.section = section;
+    this.subSection = subSection;
+    this.key = key;
+    this.expected = expected;
+  }
+
+  public RequiredOption(String section, String key, T expected) {
+    this(section, null, key, expected);
+  }
+
+  public String getSection() {
+    return section;
+  }
+
+  public String getSubSection() {
+    return subSection;
+  }
+
+  public String getKey() {
+    return key;
+  }
+
+  public T getExpected() {
+    return expected;
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/dependent/GerritConfigMap.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/dependent/GerritConfigMap.java
new file mode 100644
index 0000000..22001b9
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/dependent/GerritConfigMap.java
@@ -0,0 +1,91 @@
+// 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.dependent;
+
+import com.google.gerrit.k8s.operator.v1alpha.api.model.cluster.GerritCluster;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.gerrit.Gerrit;
+import com.google.gerrit.k8s.operator.v1alpha.gerrit.config.GerritConfigBuilder;
+import com.google.gerrit.k8s.operator.v1alpha.gerrit.config.HighAvailabilityPluginConfigBuilder;
+import com.google.gerrit.k8s.operator.v1alpha.gerrit.config.SpannerRefDbPluginConfigBuilder;
+import com.google.gerrit.k8s.operator.v1alpha.gerrit.config.ZookeeperRefDbPluginConfigBuilder;
+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(resourceDiscriminator = GerritConfigMapDiscriminator.class)
+public class GerritConfigMap extends CRUDKubernetesDependentResource<ConfigMap, Gerrit> {
+  private static final String DEFAULT_HEALTHCHECK_CONFIG =
+      "[healthcheck \"auth\"]\nenabled = false\n[healthcheck \"querychanges\"]\nenabled = false";
+
+  public GerritConfigMap() {
+    super(ConfigMap.class);
+  }
+
+  @Override
+  protected ConfigMap desired(Gerrit gerrit, Context<Gerrit> context) {
+    Map<String, String> gerritLabels =
+        GerritCluster.getLabels(
+            gerrit.getMetadata().getName(), getName(gerrit), this.getClass().getSimpleName());
+
+    Map<String, String> configFiles = gerrit.getSpec().getConfigFiles();
+
+    if (!configFiles.containsKey("gerrit.config")) {
+      configFiles.put("gerrit.config", "");
+    }
+
+    configFiles.put("gerrit.config", new GerritConfigBuilder(gerrit).build().toText());
+
+    if (gerrit.getSpec().isHighlyAvailablePrimary()) {
+      configFiles.put(
+          "high-availability.config",
+          new HighAvailabilityPluginConfigBuilder(gerrit).build().toText());
+    }
+
+    switch (gerrit.getSpec().getRefdb().getDatabase()) {
+      case ZOOKEEPER:
+        configFiles.put(
+            "zookeeper-refdb.config",
+            new ZookeeperRefDbPluginConfigBuilder(gerrit).build().toText());
+        break;
+      case SPANNER:
+        configFiles.put(
+            "spanner-refdb.config", new SpannerRefDbPluginConfigBuilder(gerrit).build().toText());
+        break;
+      default:
+        break;
+    }
+
+    if (!configFiles.containsKey("healthcheck.config")) {
+      configFiles.put("healthcheck.config", DEFAULT_HEALTHCHECK_CONFIG);
+    }
+
+    return new ConfigMapBuilder()
+        .withApiVersion("v1")
+        .withNewMetadata()
+        .withName(getName(gerrit))
+        .withNamespace(gerrit.getMetadata().getNamespace())
+        .withLabels(gerritLabels)
+        .endMetadata()
+        .withData(configFiles)
+        .build();
+  }
+
+  public static String getName(Gerrit gerrit) {
+    return String.format("%s-configmap", gerrit.getMetadata().getName());
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/dependent/GerritConfigMapDiscriminator.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/dependent/GerritConfigMapDiscriminator.java
new file mode 100644
index 0000000..6ec6b77
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/dependent/GerritConfigMapDiscriminator.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.gerrit.dependent;
+
+import com.google.gerrit.k8s.operator.v1alpha.api.model.gerrit.Gerrit;
+import io.fabric8.kubernetes.api.model.ConfigMap;
+import io.javaoperatorsdk.operator.api.reconciler.Context;
+import io.javaoperatorsdk.operator.api.reconciler.ResourceDiscriminator;
+import io.javaoperatorsdk.operator.processing.event.ResourceID;
+import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource;
+import java.util.Optional;
+
+public class GerritConfigMapDiscriminator implements ResourceDiscriminator<ConfigMap, Gerrit> {
+  @Override
+  public Optional<ConfigMap> distinguish(
+      Class<ConfigMap> resource, Gerrit primary, Context<Gerrit> context) {
+    InformerEventSource<ConfigMap, Gerrit> ies =
+        (InformerEventSource<ConfigMap, Gerrit>)
+            context.eventSourceRetriever().getResourceEventSourceFor(ConfigMap.class);
+
+    return ies.get(
+        new ResourceID(GerritConfigMap.getName(primary), primary.getMetadata().getNamespace()));
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/dependent/GerritInitConfigMap.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/dependent/GerritInitConfigMap.java
new file mode 100644
index 0000000..3b9b8b4
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/dependent/GerritInitConfigMap.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.dependent;
+
+import static com.google.gerrit.k8s.operator.v1alpha.api.model.cluster.GerritCluster.PLUGIN_CACHE_MOUNT_PATH;
+import static com.google.gerrit.k8s.operator.v1alpha.api.model.shared.GlobalRefDbConfig.RefDatabase.SPANNER;
+import static com.google.gerrit.k8s.operator.v1alpha.api.model.shared.GlobalRefDbConfig.RefDatabase.ZOOKEEPER;
+
+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.v1alpha.api.model.cluster.GerritCluster;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.gerrit.Gerrit;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.gerrit.GerritInitConfig;
+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.Locale;
+import java.util.Map;
+
+@KubernetesDependent(resourceDiscriminator = GerritInitConfigMapDiscriminator.class)
+public class GerritInitConfigMap extends CRUDKubernetesDependentResource<ConfigMap, Gerrit> {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  public GerritInitConfigMap() {
+    super(ConfigMap.class);
+  }
+
+  @Override
+  protected ConfigMap desired(Gerrit gerrit, Context<Gerrit> context) {
+    Map<String, String> gerritLabels =
+        GerritCluster.getLabels(
+            gerrit.getMetadata().getName(), getName(gerrit), this.getClass().getSimpleName());
+
+    return new ConfigMapBuilder()
+        .withApiVersion("v1")
+        .withNewMetadata()
+        .withName(getName(gerrit))
+        .withNamespace(gerrit.getMetadata().getNamespace())
+        .withLabels(gerritLabels)
+        .endMetadata()
+        .withData(Map.of("gerrit-init.yaml", getGerritInitConfig(gerrit)))
+        .build();
+  }
+
+  private String getGerritInitConfig(Gerrit gerrit) {
+    GerritInitConfig config = new GerritInitConfig();
+    config.setPlugins(gerrit.getSpec().getPlugins());
+    config.setLibs(gerrit.getSpec().getLibs());
+    config.setPluginCacheEnabled(gerrit.getSpec().getStorage().getPluginCache().isEnabled());
+    config.setPluginCacheDir(PLUGIN_CACHE_MOUNT_PATH);
+    config.setHighlyAvailable(gerrit.getSpec().isHighlyAvailablePrimary());
+
+    switch (gerrit.getSpec().getRefdb().getDatabase()) {
+      case ZOOKEEPER:
+        config.setRefdb(ZOOKEEPER.toString().toLowerCase(Locale.US));
+        break;
+      case SPANNER:
+        config.setRefdb(SPANNER.toString().toLowerCase(Locale.US));
+        break;
+      default:
+        break;
+    }
+
+    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);
+    }
+  }
+
+  public static String getName(Gerrit gerrit) {
+    return String.format("%s-init-configmap", gerrit.getMetadata().getName());
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/dependent/GerritInitConfigMapDiscriminator.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/dependent/GerritInitConfigMapDiscriminator.java
new file mode 100644
index 0000000..5494f5a
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/dependent/GerritInitConfigMapDiscriminator.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.gerrit.dependent;
+
+import com.google.gerrit.k8s.operator.v1alpha.api.model.gerrit.Gerrit;
+import io.fabric8.kubernetes.api.model.ConfigMap;
+import io.javaoperatorsdk.operator.api.reconciler.Context;
+import io.javaoperatorsdk.operator.api.reconciler.ResourceDiscriminator;
+import io.javaoperatorsdk.operator.processing.event.ResourceID;
+import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource;
+import java.util.Optional;
+
+public class GerritInitConfigMapDiscriminator implements ResourceDiscriminator<ConfigMap, Gerrit> {
+  @Override
+  public Optional<ConfigMap> distinguish(
+      Class<ConfigMap> resource, Gerrit primary, Context<Gerrit> context) {
+    InformerEventSource<ConfigMap, Gerrit> ies =
+        (InformerEventSource<ConfigMap, Gerrit>)
+            context.eventSourceRetriever().getResourceEventSourceFor(ConfigMap.class);
+
+    return ies.get(
+        new ResourceID(GerritInitConfigMap.getName(primary), primary.getMetadata().getNamespace()));
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/dependent/GerritSecret.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/dependent/GerritSecret.java
new file mode 100644
index 0000000..ac65dcd
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/dependent/GerritSecret.java
@@ -0,0 +1,67 @@
+// Copyright (C) 2023 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.dependent;
+
+import com.google.gerrit.k8s.operator.v1alpha.api.model.gerrit.Gerrit;
+import io.fabric8.kubernetes.api.model.Secret;
+import io.javaoperatorsdk.operator.api.reconciler.Context;
+import io.javaoperatorsdk.operator.api.reconciler.dependent.ReconcileResult;
+import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent;
+import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependentResource;
+import io.javaoperatorsdk.operator.processing.event.ResourceID;
+import io.javaoperatorsdk.operator.processing.event.source.SecondaryToPrimaryMapper;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+@KubernetesDependent
+public class GerritSecret extends KubernetesDependentResource<Secret, Gerrit>
+    implements SecondaryToPrimaryMapper<Secret> {
+
+  public static final String CONTEXT_SECRET_VERSION_KEY = "gerrit-secret-version";
+
+  public GerritSecret() {
+    super(Secret.class);
+  }
+
+  @Override
+  public Set<ResourceID> toPrimaryResourceIDs(Secret secret) {
+    return client
+        .resources(Gerrit.class)
+        .inNamespace(secret.getMetadata().getNamespace())
+        .list()
+        .getItems()
+        .stream()
+        .filter(g -> g.getSpec().getSecretRef().equals(secret.getMetadata().getName()))
+        .map(g -> ResourceID.fromResource(g))
+        .collect(Collectors.toSet());
+  }
+
+  @Override
+  protected ReconcileResult<Secret> reconcile(
+      Gerrit primary, Secret actualResource, Context<Gerrit> context) {
+    Secret sec =
+        client
+            .secrets()
+            .inNamespace(primary.getMetadata().getNamespace())
+            .withName(primary.getSpec().getSecretRef())
+            .get();
+    if (sec != null) {
+      context
+          .managedDependentResourceContext()
+          .put(CONTEXT_SECRET_VERSION_KEY, sec.getMetadata().getResourceVersion());
+    }
+    return ReconcileResult.noOperation(actualResource);
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/dependent/GerritService.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/dependent/GerritService.java
new file mode 100644
index 0000000..888903c
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/dependent/GerritService.java
@@ -0,0 +1,108 @@
+// 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.dependent;
+
+import static com.google.gerrit.k8s.operator.gerrit.dependent.GerritStatefulSet.HTTP_PORT;
+import static com.google.gerrit.k8s.operator.gerrit.dependent.GerritStatefulSet.SSH_PORT;
+
+import com.google.gerrit.k8s.operator.gerrit.GerritReconciler;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.cluster.GerritCluster;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.gerrit.Gerrit;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.gerrit.GerritTemplate;
+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 GerritService extends CRUDKubernetesDependentResource<Service, Gerrit> {
+  public static final String HTTP_PORT_NAME = "http";
+
+  public GerritService() {
+    super(Service.class);
+  }
+
+  @Override
+  protected Service desired(Gerrit gerrit, Context<Gerrit> context) {
+    return new ServiceBuilder()
+        .withApiVersion("v1")
+        .withNewMetadata()
+        .withName(getName(gerrit))
+        .withNamespace(gerrit.getMetadata().getNamespace())
+        .withLabels(getLabels(gerrit))
+        .endMetadata()
+        .withNewSpec()
+        .withType(gerrit.getSpec().getService().getType())
+        .withPorts(getServicePorts(gerrit))
+        .withSelector(GerritStatefulSet.getSelectorLabels(gerrit))
+        .endSpec()
+        .build();
+  }
+
+  public static String getName(Gerrit gerrit) {
+    return gerrit.getMetadata().getName();
+  }
+
+  public static String getName(String gerritName) {
+    return gerritName;
+  }
+
+  public static String getName(GerritTemplate gerrit) {
+    return gerrit.getMetadata().getName();
+  }
+
+  public static String getHostname(Gerrit gerrit) {
+    return getHostname(gerrit.getMetadata().getName(), gerrit.getMetadata().getNamespace());
+  }
+
+  public static String getHostname(String name, String namespace) {
+    return String.format("%s.%s.svc.cluster.local", name, namespace);
+  }
+
+  public static String getUrl(Gerrit gerrit) {
+    return String.format(
+        "http://%s:%s", getHostname(gerrit), gerrit.getSpec().getService().getHttpPort());
+  }
+
+  public static Map<String, String> getLabels(Gerrit gerrit) {
+    return GerritCluster.getLabels(
+        gerrit.getMetadata().getName(), "gerrit-service", GerritReconciler.class.getSimpleName());
+  }
+
+  private static List<ServicePort> getServicePorts(Gerrit gerrit) {
+    List<ServicePort> ports = new ArrayList<>();
+    ports.add(
+        new ServicePortBuilder()
+            .withName(HTTP_PORT_NAME)
+            .withPort(gerrit.getSpec().getService().getHttpPort())
+            .withNewTargetPort(HTTP_PORT)
+            .build());
+    if (gerrit.isSshEnabled()) {
+      ports.add(
+          new ServicePortBuilder()
+              .withName("ssh")
+              .withPort(gerrit.getSpec().getService().getSshPort())
+              .withNewTargetPort(SSH_PORT)
+              .build());
+    }
+    return ports;
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/dependent/GerritStatefulSet.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/dependent/GerritStatefulSet.java
new file mode 100644
index 0000000..d319f4c
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/gerrit/dependent/GerritStatefulSet.java
@@ -0,0 +1,364 @@
+// 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.dependent;
+
+import static com.google.gerrit.k8s.operator.gerrit.dependent.GerritSecret.CONTEXT_SECRET_VERSION_KEY;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.k8s.operator.gerrit.GerritReconciler;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.cluster.GerritCluster;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.gerrit.Gerrit;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.shared.ContainerImageConfig;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.shared.NfsWorkaroundConfig;
+import io.fabric8.kubernetes.api.model.Container;
+import io.fabric8.kubernetes.api.model.ContainerPort;
+import io.fabric8.kubernetes.api.model.EnvVar;
+import io.fabric8.kubernetes.api.model.EnvVarBuilder;
+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.sql.Timestamp;
+import java.text.SimpleDateFormat;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+
+@KubernetesDependent
+public class GerritStatefulSet extends CRUDKubernetesDependentResource<StatefulSet, Gerrit> {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+  private static final SimpleDateFormat RFC3339 = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'");
+
+  private static final String SITE_VOLUME_NAME = "gerrit-site";
+  public static final int HTTP_PORT = 8080;
+  public static final int SSH_PORT = 29418;
+  public static final int JGROUPS_PORT = 7800;
+  public static final int DEBUG_PORT = 8000;
+
+  public GerritStatefulSet() {
+    super(StatefulSet.class);
+  }
+
+  @Override
+  protected StatefulSet desired(Gerrit gerrit, Context<Gerrit> context) {
+    StatefulSetBuilder stsBuilder = new StatefulSetBuilder();
+
+    List<Container> initContainers = new ArrayList<>();
+
+    NfsWorkaroundConfig nfsWorkaround =
+        gerrit.getSpec().getStorage().getStorageClasses().getNfsWorkaround();
+    if (nfsWorkaround.isEnabled() && nfsWorkaround.isChownOnStartup()) {
+      boolean hasIdmapdConfig =
+          gerrit.getSpec().getStorage().getStorageClasses().getNfsWorkaround().getIdmapdConfig()
+              != null;
+      ContainerImageConfig images = gerrit.getSpec().getContainerImages();
+
+      if (gerrit.getSpec().isHighlyAvailablePrimary()) {
+
+        initContainers.add(
+            GerritCluster.createNfsInitContainer(
+                hasIdmapdConfig, images, List.of(GerritCluster.getHAShareVolumeMount())));
+      } else {
+        initContainers.add(GerritCluster.createNfsInitContainer(hasIdmapdConfig, images));
+      }
+    }
+
+    Map<String, String> replicaSetAnnotations = new HashMap<>();
+    if (gerrit.getStatus() != null && isGerritRestartRequired(gerrit, context)) {
+      replicaSetAnnotations.put(
+          "kubectl.kubernetes.io/restartedAt", RFC3339.format(Timestamp.from(Instant.now())));
+    } else {
+      Optional<StatefulSet> existingSts = context.getSecondaryResource(StatefulSet.class);
+      if (existingSts.isPresent()) {
+        Map<String, String> existingAnnotations =
+            existingSts.get().getSpec().getTemplate().getMetadata().getAnnotations();
+        if (existingAnnotations.containsKey("kubectl.kubernetes.io/restartedAt")) {
+          replicaSetAnnotations.put(
+              "kubectl.kubernetes.io/restartedAt",
+              existingAnnotations.get("kubectl.kubernetes.io/restartedAt"));
+        }
+      }
+    }
+
+    stsBuilder
+        .withApiVersion("apps/v1")
+        .withNewMetadata()
+        .withName(gerrit.getMetadata().getName())
+        .withNamespace(gerrit.getMetadata().getNamespace())
+        .withLabels(getLabels(gerrit))
+        .endMetadata()
+        .withNewSpec()
+        .withServiceName(GerritService.getName(gerrit))
+        .withReplicas(gerrit.getSpec().getReplicas())
+        .withNewUpdateStrategy()
+        .withNewRollingUpdate()
+        .withPartition(gerrit.getSpec().getUpdatePartition())
+        .endRollingUpdate()
+        .endUpdateStrategy()
+        .withNewSelector()
+        .withMatchLabels(getSelectorLabels(gerrit))
+        .endSelector()
+        .withNewTemplate()
+        .withNewMetadata()
+        .withAnnotations(replicaSetAnnotations)
+        .withLabels(getLabels(gerrit))
+        .endMetadata()
+        .withNewSpec()
+        .withServiceAccount(gerrit.getSpec().getServiceAccount())
+        .withTolerations(gerrit.getSpec().getTolerations())
+        .withTopologySpreadConstraints(gerrit.getSpec().getTopologySpreadConstraints())
+        .withAffinity(gerrit.getSpec().getAffinity())
+        .withPriorityClassName(gerrit.getSpec().getPriorityClassName())
+        .withTerminationGracePeriodSeconds(gerrit.getSpec().getGracefulStopTimeout())
+        .addAllToImagePullSecrets(gerrit.getSpec().getContainerImages().getImagePullSecrets())
+        .withNewSecurityContext()
+        .withFsGroup(100L)
+        .endSecurityContext()
+        .addNewInitContainer()
+        .withName("gerrit-init")
+        .withEnv(getEnvVars(gerrit))
+        .withImagePullPolicy(gerrit.getSpec().getContainerImages().getImagePullPolicy())
+        .withImage(
+            gerrit.getSpec().getContainerImages().getGerritImages().getFullImageName("gerrit-init"))
+        .withResources(gerrit.getSpec().getResources())
+        .addAllToVolumeMounts(getVolumeMounts(gerrit, true))
+        .endInitContainer()
+        .addAllToInitContainers(initContainers)
+        .addNewContainer()
+        .withName("gerrit")
+        .withImagePullPolicy(gerrit.getSpec().getContainerImages().getImagePullPolicy())
+        .withImage(
+            gerrit.getSpec().getContainerImages().getGerritImages().getFullImageName("gerrit"))
+        .withNewLifecycle()
+        .withNewPreStop()
+        .withNewExec()
+        .withCommand(
+            "/bin/ash/", "-c", "kill -2 $(pidof java) && tail --pid=$(pidof java) -f /dev/null")
+        .endExec()
+        .endPreStop()
+        .endLifecycle()
+        .withEnv(getEnvVars(gerrit))
+        .withPorts(getContainerPorts(gerrit))
+        .withResources(gerrit.getSpec().getResources())
+        .withStartupProbe(gerrit.getSpec().getStartupProbe())
+        .withReadinessProbe(gerrit.getSpec().getReadinessProbe())
+        .withLivenessProbe(gerrit.getSpec().getLivenessProbe())
+        .addAllToVolumeMounts(getVolumeMounts(gerrit, false))
+        .endContainer()
+        .addAllToVolumes(getVolumes(gerrit))
+        .endSpec()
+        .endTemplate()
+        .addNewVolumeClaimTemplate()
+        .withNewMetadata()
+        .withName(SITE_VOLUME_NAME)
+        .withLabels(getSelectorLabels(gerrit))
+        .endMetadata()
+        .withNewSpec()
+        .withAccessModes("ReadWriteOnce")
+        .withNewResources()
+        .withRequests(Map.of("storage", gerrit.getSpec().getSite().getSize()))
+        .endResources()
+        .withStorageClassName(gerrit.getSpec().getStorage().getStorageClasses().getReadWriteOnce())
+        .endSpec()
+        .endVolumeClaimTemplate()
+        .endSpec();
+
+    return stsBuilder.build();
+  }
+
+  private static String getComponentName(Gerrit gerrit) {
+    return String.format("gerrit-statefulset-%s", gerrit.getMetadata().getName());
+  }
+
+  public static Map<String, String> getSelectorLabels(Gerrit gerrit) {
+    return GerritCluster.getSelectorLabels(
+        gerrit.getMetadata().getName(), getComponentName(gerrit));
+  }
+
+  private static Map<String, String> getLabels(Gerrit gerrit) {
+    return GerritCluster.getLabels(
+        gerrit.getMetadata().getName(),
+        getComponentName(gerrit),
+        GerritReconciler.class.getSimpleName());
+  }
+
+  private Set<Volume> getVolumes(Gerrit gerrit) {
+    Set<Volume> volumes = new HashSet<>();
+
+    volumes.add(
+        GerritCluster.getSharedVolume(
+            gerrit.getSpec().getStorage().getSharedStorage().getExternalPVC()));
+
+    volumes.add(
+        new VolumeBuilder()
+            .withName("gerrit-init-config")
+            .withNewConfigMap()
+            .withName(GerritInitConfigMap.getName(gerrit))
+            .endConfigMap()
+            .build());
+
+    volumes.add(
+        new VolumeBuilder()
+            .withName("gerrit-config")
+            .withNewConfigMap()
+            .withName(GerritConfigMap.getName(gerrit))
+            .endConfigMap()
+            .build());
+
+    volumes.add(
+        new VolumeBuilder()
+            .withName(gerrit.getSpec().getSecretRef())
+            .withNewSecret()
+            .withSecretName(gerrit.getSpec().getSecretRef())
+            .endSecret()
+            .build());
+
+    NfsWorkaroundConfig nfsWorkaround =
+        gerrit.getSpec().getStorage().getStorageClasses().getNfsWorkaround();
+    if (nfsWorkaround.isEnabled() && nfsWorkaround.getIdmapdConfig() != null) {
+      volumes.add(GerritCluster.getNfsImapdConfigVolume());
+    }
+
+    return volumes;
+  }
+
+  private Set<VolumeMount> getVolumeMounts(Gerrit gerrit, boolean isInitContainer) {
+    Set<VolumeMount> volumeMounts = new HashSet<>();
+    volumeMounts.add(
+        new VolumeMountBuilder().withName(SITE_VOLUME_NAME).withMountPath("/var/gerrit").build());
+    if (gerrit.getSpec().isHighlyAvailablePrimary()) {
+      volumeMounts.add(GerritCluster.getHAShareVolumeMount());
+    }
+    volumeMounts.add(GerritCluster.getGitRepositoriesVolumeMount());
+    volumeMounts.add(GerritCluster.getLogsVolumeMount());
+    volumeMounts.add(
+        new VolumeMountBuilder()
+            .withName("gerrit-config")
+            .withMountPath("/var/mnt/etc/config")
+            .build());
+
+    volumeMounts.add(
+        new VolumeMountBuilder()
+            .withName(gerrit.getSpec().getSecretRef())
+            .withMountPath("/var/mnt/etc/secret")
+            .build());
+
+    if (isInitContainer) {
+      volumeMounts.add(
+          new VolumeMountBuilder()
+              .withName("gerrit-init-config")
+              .withMountPath("/var/config")
+              .build());
+
+      if (gerrit.getSpec().getStorage().getPluginCache().isEnabled()
+          && gerrit.getSpec().getPlugins().stream().anyMatch(p -> !p.isPackagedPlugin())) {
+        volumeMounts.add(GerritCluster.getPluginCacheVolumeMount());
+      }
+    }
+
+    NfsWorkaroundConfig nfsWorkaround =
+        gerrit.getSpec().getStorage().getStorageClasses().getNfsWorkaround();
+    if (nfsWorkaround.isEnabled() && nfsWorkaround.getIdmapdConfig() != null) {
+      volumeMounts.add(GerritCluster.getNfsImapdConfigVolumeMount());
+    }
+
+    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.isSshEnabled()) {
+      containerPorts.add(new ContainerPort(SSH_PORT, null, null, "ssh", null));
+    }
+
+    if (gerrit.getSpec().isHighlyAvailablePrimary()) {
+      containerPorts.add(new ContainerPort(JGROUPS_PORT, null, null, "jgroups", null));
+    }
+
+    if (gerrit.getSpec().getDebug().isEnabled()) {
+      containerPorts.add(new ContainerPort(DEBUG_PORT, null, null, "debug", null));
+    }
+
+    return containerPorts;
+  }
+
+  private List<EnvVar> getEnvVars(Gerrit gerrit) {
+    List<EnvVar> envVars = new ArrayList<>();
+    envVars.add(GerritCluster.getPodNameEnvVar());
+    if (gerrit.getSpec().isHighlyAvailablePrimary()) {
+      envVars.add(
+          new EnvVarBuilder()
+              .withName("GERRIT_URL")
+              .withValue(
+                  String.format(
+                      "http://$(POD_NAME).%s:%s", GerritService.getHostname(gerrit), HTTP_PORT))
+              .build());
+    }
+    return envVars;
+  }
+
+  private boolean isGerritRestartRequired(Gerrit gerrit, Context<Gerrit> context) {
+    if (wasConfigMapUpdated(GerritInitConfigMap.getName(gerrit), gerrit)
+        || wasConfigMapUpdated(GerritConfigMap.getName(gerrit), gerrit)) {
+      return true;
+    }
+
+    String secretName = gerrit.getSpec().getSecretRef();
+    Optional<String> gerritSecret =
+        context.managedDependentResourceContext().get(CONTEXT_SECRET_VERSION_KEY, String.class);
+    if (gerritSecret.isPresent()) {
+      String secVersion = gerritSecret.get();
+      if (!secVersion.equals(gerrit.getStatus().getAppliedSecretVersions().get(secretName))) {
+        logger.atFine().log(
+            "Looking up Secret: %s; Installed secret resource version: %s; Resource version known to Gerrit: %s",
+            secretName, secVersion, gerrit.getStatus().getAppliedSecretVersions().get(secretName));
+        return true;
+      }
+    }
+    return false;
+  }
+
+  private boolean wasConfigMapUpdated(String configMapName, Gerrit gerrit) {
+    String configMapVersion =
+        client
+            .configMaps()
+            .inNamespace(gerrit.getMetadata().getNamespace())
+            .withName(configMapName)
+            .get()
+            .getMetadata()
+            .getResourceVersion();
+    String knownConfigMapVersion =
+        gerrit.getStatus().getAppliedConfigMapVersions().get(configMapName);
+    if (!configMapVersion.equals(knownConfigMapVersion)) {
+      logger.atInfo().log(
+          "Looking up ConfigMap: %s; Installed configmap resource version: %s; Resource version known to Gerrit: %s",
+          configMapName, configMapVersion, knownConfigMapVersion);
+      return true;
+    }
+    return false;
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/gitgc/GitGarbageCollectionConflictException.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/gitgc/GitGarbageCollectionConflictException.java
new file mode 100644
index 0000000..8d6bb26
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/gitgc/GitGarbageCollectionConflictException.java
@@ -0,0 +1,30 @@
+// 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.gitgc;
+
+import java.util.Collection;
+
+public class GitGarbageCollectionConflictException extends RuntimeException {
+
+  private static final long serialVersionUID = 1L;
+
+  public GitGarbageCollectionConflictException(Collection<String> projectsIntercept) {
+    super(String.format("Found conflicting GC jobs for projects: %s", projectsIntercept));
+  }
+
+  public GitGarbageCollectionConflictException(String s) {
+    super(s);
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/gitgc/GitGarbageCollectionReconciler.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/gitgc/GitGarbageCollectionReconciler.java
new file mode 100644
index 0000000..8d28fea
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/gitgc/GitGarbageCollectionReconciler.java
@@ -0,0 +1,152 @@
+// 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.gitgc;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.k8s.operator.gitgc.dependent.GitGarbageCollectionCronJob;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.cluster.GerritCluster;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.gitgc.GitGarbageCollection;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.gitgc.GitGarbageCollectionStatus;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.gitgc.GitGarbageCollectionStatus.GitGcState;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+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.ErrorStatusHandler;
+import io.javaoperatorsdk.operator.api.reconciler.ErrorStatusUpdateControl;
+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.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.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+@Singleton
+@ControllerConfiguration
+public class GitGarbageCollectionReconciler
+    implements Reconciler<GitGarbageCollection>,
+        EventSourceInitializer<GitGarbageCollection>,
+        ErrorStatusHandler<GitGarbageCollection> {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+  private final KubernetesClient client;
+
+  private GitGarbageCollectionCronJob dependentCronJob;
+
+  @Inject
+  public GitGarbageCollectionReconciler(KubernetesClient client) {
+    this.client = client;
+    this.dependentCronJob = new GitGarbageCollectionCronJob();
+    this.dependentCronJob.setKubernetesClient(client);
+  }
+
+  @Override
+  public Map<String, EventSource> prepareEventSources(
+      EventSourceContext<GitGarbageCollection> context) {
+    final SecondaryToPrimaryMapper<GitGarbageCollection> specificProjectGitGcMapper =
+        (GitGarbageCollection gc) ->
+            context
+                .getPrimaryCache()
+                .list(gitGc -> gitGc.getSpec().getProjects().isEmpty())
+                .map(ResourceID::fromResource)
+                .collect(Collectors.toSet());
+
+    InformerEventSource<GitGarbageCollection, GitGarbageCollection> gitGcEventSource =
+        new InformerEventSource<>(
+            InformerConfiguration.from(GitGarbageCollection.class, context)
+                .withSecondaryToPrimaryMapper(specificProjectGitGcMapper)
+                .build(),
+            context);
+
+    final SecondaryToPrimaryMapper<GerritCluster> gerritClusterMapper =
+        (GerritCluster cluster) ->
+            context
+                .getPrimaryCache()
+                .list(gitGc -> gitGc.getSpec().getCluster().equals(cluster.getMetadata().getName()))
+                .map(ResourceID::fromResource)
+                .collect(Collectors.toSet());
+
+    InformerEventSource<GerritCluster, GitGarbageCollection> gerritClusterEventSource =
+        new InformerEventSource<>(
+            InformerConfiguration.from(GerritCluster.class, context)
+                .withSecondaryToPrimaryMapper(gerritClusterMapper)
+                .build(),
+            context);
+
+    return EventSourceInitializer.nameEventSources(
+        gitGcEventSource, gerritClusterEventSource, dependentCronJob.initEventSource(context));
+  }
+
+  @Override
+  public UpdateControl<GitGarbageCollection> reconcile(
+      GitGarbageCollection gitGc, Context<GitGarbageCollection> context) {
+    if (gitGc.getSpec().getProjects().isEmpty()) {
+      gitGc = excludeProjectsHandledSeparately(gitGc);
+    }
+
+    dependentCronJob.reconcile(gitGc, context);
+    return UpdateControl.updateStatus(updateGitGcStatus(gitGc));
+  }
+
+  private GitGarbageCollection updateGitGcStatus(GitGarbageCollection gitGc) {
+    GitGarbageCollectionStatus status = gitGc.getStatus();
+    if (status == null) {
+      status = new GitGarbageCollectionStatus();
+    }
+    status.setReplicateAll(gitGc.getSpec().getProjects().isEmpty());
+    status.setState(GitGcState.ACTIVE);
+    gitGc.setStatus(status);
+    return gitGc;
+  }
+
+  private GitGarbageCollection excludeProjectsHandledSeparately(GitGarbageCollection currentGitGc) {
+    List<GitGarbageCollection> gitGcs =
+        client
+            .resources(GitGarbageCollection.class)
+            .inNamespace(currentGitGc.getMetadata().getNamespace())
+            .list()
+            .getItems();
+    gitGcs.remove(currentGitGc);
+    GitGarbageCollectionStatus currentGitGcStatus = currentGitGc.getStatus();
+    currentGitGcStatus.resetExcludedProjects();
+    for (GitGarbageCollection gc : gitGcs) {
+      currentGitGcStatus.excludeProjects(gc.getSpec().getProjects());
+    }
+    currentGitGc.setStatus(currentGitGcStatus);
+
+    return currentGitGc;
+  }
+
+  @Override
+  public ErrorStatusUpdateControl<GitGarbageCollection> updateErrorStatus(
+      GitGarbageCollection gitGc, Context<GitGarbageCollection> context, Exception e) {
+    GitGarbageCollectionStatus status = new GitGarbageCollectionStatus();
+    if (e instanceof GitGarbageCollectionConflictException) {
+      status.setState(GitGcState.CONFLICT);
+    } else {
+      logger.atSevere().withCause(e).log("Failed reconcile with message: %s", e.getMessage());
+      status.setState(GitGcState.ERROR);
+    }
+    gitGc.setStatus(status);
+
+    return ErrorStatusUpdateControl.updateStatus(gitGc);
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/gitgc/dependent/GitGarbageCollectionCronJob.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/gitgc/dependent/GitGarbageCollectionCronJob.java
new file mode 100644
index 0000000..254bbab
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/gitgc/dependent/GitGarbageCollectionCronJob.java
@@ -0,0 +1,186 @@
+// 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.gitgc.dependent;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.cluster.GerritCluster;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.gitgc.GitGarbageCollection;
+import io.fabric8.kubernetes.api.model.Container;
+import io.fabric8.kubernetes.api.model.ContainerBuilder;
+import io.fabric8.kubernetes.api.model.Volume;
+import io.fabric8.kubernetes.api.model.VolumeMount;
+import io.fabric8.kubernetes.api.model.batch.v1.CronJob;
+import io.fabric8.kubernetes.api.model.batch.v1.CronJobBuilder;
+import io.fabric8.kubernetes.api.model.batch.v1.JobTemplateSpec;
+import io.fabric8.kubernetes.api.model.batch.v1.JobTemplateSpecBuilder;
+import io.javaoperatorsdk.operator.api.reconciler.Context;
+import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+public class GitGarbageCollectionCronJob
+    extends CRUDKubernetesDependentResource<CronJob, GitGarbageCollection> {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  public GitGarbageCollectionCronJob() {
+    super(CronJob.class);
+  }
+
+  @Override
+  protected CronJob desired(GitGarbageCollection gitGc, Context<GitGarbageCollection> context) {
+    String ns = gitGc.getMetadata().getNamespace();
+    String name = gitGc.getMetadata().getName();
+    GerritCluster gerritCluster =
+        client
+            .resources(GerritCluster.class)
+            .inNamespace(ns)
+            .withName(gitGc.getSpec().getCluster())
+            .get();
+    logger.atInfo().log("Reconciling GitGc with name: %s/%s", ns, name);
+
+    Map<String, String> gitGcLabels =
+        gerritCluster.getLabels("GitGc", this.getClass().getSimpleName());
+
+    List<Container> initContainers = new ArrayList<>();
+    if (gerritCluster.getSpec().getStorage().getStorageClasses().getNfsWorkaround().isEnabled()
+        && gerritCluster
+            .getSpec()
+            .getStorage()
+            .getStorageClasses()
+            .getNfsWorkaround()
+            .isChownOnStartup()) {
+      initContainers.add(gerritCluster.createNfsInitContainer());
+    }
+
+    JobTemplateSpec gitGcJobTemplate =
+        new JobTemplateSpecBuilder()
+            .withNewSpec()
+            .withNewTemplate()
+            .withNewMetadata()
+            .withAnnotations(
+                Map.of(
+                    "sidecar.istio.io/inject",
+                    "false",
+                    "cluster-autoscaler.kubernetes.io/safe-to-evict",
+                    "false"))
+            .withLabels(gitGcLabels)
+            .endMetadata()
+            .withNewSpec()
+            .withTolerations(gitGc.getSpec().getTolerations())
+            .withAffinity(gitGc.getSpec().getAffinity())
+            .addAllToImagePullSecrets(
+                gerritCluster.getSpec().getContainerImages().getImagePullSecrets())
+            .withRestartPolicy("OnFailure")
+            .withNewSecurityContext()
+            .withFsGroup(100L)
+            .endSecurityContext()
+            .addToContainers(buildGitGcContainer(gitGc, gerritCluster))
+            .withVolumes(getVolumes(gerritCluster))
+            .endSpec()
+            .endTemplate()
+            .endSpec()
+            .build();
+
+    return new CronJobBuilder()
+        .withApiVersion("batch/v1")
+        .withNewMetadata()
+        .withNamespace(ns)
+        .withName(name)
+        .withLabels(gitGcLabels)
+        .withAnnotations(
+            Collections.singletonMap("app.kubernetes.io/managed-by", "gerrit-operator"))
+        .addNewOwnerReference()
+        .withApiVersion(gitGc.getApiVersion())
+        .withKind(gitGc.getKind())
+        .withName(name)
+        .withUid(gitGc.getMetadata().getUid())
+        .endOwnerReference()
+        .endMetadata()
+        .withNewSpec()
+        .withSchedule(gitGc.getSpec().getSchedule())
+        .withConcurrencyPolicy("Forbid")
+        .withJobTemplate(gitGcJobTemplate)
+        .endSpec()
+        .build();
+  }
+
+  private Container buildGitGcContainer(GitGarbageCollection gitGc, GerritCluster gerritCluster) {
+    List<VolumeMount> volumeMounts =
+        List.of(
+            GerritCluster.getGitRepositoriesVolumeMount("/var/gerrit/git"),
+            GerritCluster.getLogsVolumeMount("/var/log/git"));
+
+    if (gerritCluster.getSpec().getStorage().getStorageClasses().getNfsWorkaround().isEnabled()
+        && gerritCluster
+                .getSpec()
+                .getStorage()
+                .getStorageClasses()
+                .getNfsWorkaround()
+                .getIdmapdConfig()
+            != null) {
+      volumeMounts.add(GerritCluster.getNfsImapdConfigVolumeMount());
+    }
+
+    ContainerBuilder gitGcContainerBuilder =
+        new ContainerBuilder()
+            .withName("git-gc")
+            .withImagePullPolicy(gerritCluster.getSpec().getContainerImages().getImagePullPolicy())
+            .withImage(
+                gerritCluster
+                    .getSpec()
+                    .getContainerImages()
+                    .getGerritImages()
+                    .getFullImageName("git-gc"))
+            .withResources(gitGc.getSpec().getResources())
+            .withEnv(GerritCluster.getPodNameEnvVar())
+            .withVolumeMounts(volumeMounts);
+
+    ArrayList<String> args = new ArrayList<>();
+    for (String project : gitGc.getSpec().getProjects()) {
+      args.add("-p");
+      args.add(project);
+    }
+    for (String project : gitGc.getStatus().getExcludedProjects()) {
+      args.add("-s");
+      args.add(project);
+    }
+    gitGcContainerBuilder.addAllToArgs(args);
+
+    return gitGcContainerBuilder.build();
+  }
+
+  private List<Volume> getVolumes(GerritCluster gerritCluster) {
+    List<Volume> volumes = new ArrayList<>();
+
+    volumes.add(
+        GerritCluster.getSharedVolume(
+            gerritCluster.getSpec().getStorage().getSharedStorage().getExternalPVC()));
+
+    if (gerritCluster.getSpec().getStorage().getStorageClasses().getNfsWorkaround().isEnabled()) {
+      if (gerritCluster
+              .getSpec()
+              .getStorage()
+              .getStorageClasses()
+              .getNfsWorkaround()
+              .getIdmapdConfig()
+          != null) {
+        volumes.add(GerritCluster.getNfsImapdConfigVolume());
+      }
+    }
+    return volumes;
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/Constants.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/Constants.java
new file mode 100644
index 0000000..1a7f3b2
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/Constants.java
@@ -0,0 +1,22 @@
+// Copyright (C) 2023 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.network;
+
+package com.google.gerrit.k8s.operator.network;
+
+public class Constants {
+  public static String UPLOAD_PACK_URL_PATTERN = "/.*/git-upload-pack";
+  public static String INFO_REFS_PATTERN = "/.*/info/refs";
+  public static String RECEIVE_PACK_URL_PATTERN = "/.*/git-receive-pack";
+  public static String PROJECTS_URL_PATTERN = "/a/projects/.*";
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/GerritClusterIngressCondition.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/GerritClusterIngressCondition.java
new file mode 100644
index 0000000..9077f1b
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/GerritClusterIngressCondition.java
@@ -0,0 +1,33 @@
+// Copyright (C) 2023 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.network;
+
+import com.google.gerrit.k8s.operator.v1alpha.api.model.network.GerritNetwork;
+import io.fabric8.kubernetes.api.model.networking.v1.Ingress;
+import io.javaoperatorsdk.operator.api.reconciler.Context;
+import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource;
+import io.javaoperatorsdk.operator.processing.dependent.workflow.Condition;
+
+public class GerritClusterIngressCondition implements Condition<Ingress, GerritNetwork> {
+
+  @Override
+  public boolean isMet(
+      DependentResource<Ingress, GerritNetwork> dependentResource,
+      GerritNetwork gerritNetwork,
+      Context<GerritNetwork> context) {
+    return gerritNetwork.getSpec().getIngress().isEnabled()
+        && (gerritNetwork.hasReceiver() || gerritNetwork.hasGerrits());
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/GerritNetworkReconcilerProvider.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/GerritNetworkReconcilerProvider.java
new file mode 100644
index 0000000..a614e92
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/GerritNetworkReconcilerProvider.java
@@ -0,0 +1,48 @@
+// Copyright (C) 2023 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.network;
+
+import com.google.gerrit.k8s.operator.network.ambassador.GerritAmbassadorReconciler;
+import com.google.gerrit.k8s.operator.network.ingress.GerritIngressReconciler;
+import com.google.gerrit.k8s.operator.network.istio.GerritIstioReconciler;
+import com.google.gerrit.k8s.operator.network.none.GerritNoIngressReconciler;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.network.GerritNetwork;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.name.Named;
+import io.javaoperatorsdk.operator.api.reconciler.Reconciler;
+
+public class GerritNetworkReconcilerProvider implements Provider<Reconciler<GerritNetwork>> {
+  private final IngressType ingressType;
+
+  @Inject
+  public GerritNetworkReconcilerProvider(@Named("IngressType") IngressType ingressType) {
+    this.ingressType = ingressType;
+  }
+
+  @Override
+  public Reconciler<GerritNetwork> get() {
+    switch (ingressType) {
+      case INGRESS:
+        return new GerritIngressReconciler();
+      case ISTIO:
+        return new GerritIstioReconciler();
+      case AMBASSADOR:
+        return new GerritAmbassadorReconciler();
+      default:
+        return new GerritNoIngressReconciler();
+    }
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/IngressType.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/IngressType.java
new file mode 100644
index 0000000..9c5383c
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/IngressType.java
@@ -0,0 +1,22 @@
+// Copyright (C) 2023 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.network;
+
+public enum IngressType {
+  NONE,
+  INGRESS,
+  ISTIO,
+  AMBASSADOR
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/ambassador/GerritAmbassadorReconciler.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/ambassador/GerritAmbassadorReconciler.java
new file mode 100644
index 0000000..34dc9db
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/ambassador/GerritAmbassadorReconciler.java
@@ -0,0 +1,169 @@
+// Copyright (C) 2023 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.network.ambassador;
+
+import static com.google.gerrit.k8s.operator.network.ambassador.GerritAmbassadorReconciler.MAPPING_EVENT_SOURCE;
+import static com.google.gerrit.k8s.operator.network.ambassador.dependent.GerritClusterHost.GERRIT_HOST;
+import static com.google.gerrit.k8s.operator.network.ambassador.dependent.GerritClusterMapping.GERRIT_MAPPING;
+import static com.google.gerrit.k8s.operator.network.ambassador.dependent.GerritClusterMappingGETReplica.GERRIT_MAPPING_GET_REPLICA;
+import static com.google.gerrit.k8s.operator.network.ambassador.dependent.GerritClusterMappingPOSTReplica.GERRIT_MAPPING_POST_REPLICA;
+import static com.google.gerrit.k8s.operator.network.ambassador.dependent.GerritClusterMappingPrimary.GERRIT_MAPPING_PRIMARY;
+import static com.google.gerrit.k8s.operator.network.ambassador.dependent.GerritClusterMappingReceiver.GERRIT_MAPPING_RECEIVER;
+import static com.google.gerrit.k8s.operator.network.ambassador.dependent.GerritClusterMappingReceiverGET.GERRIT_MAPPING_RECEIVER_GET;
+import static com.google.gerrit.k8s.operator.network.ambassador.dependent.GerritClusterTLSContext.GERRIT_TLS_CONTEXT;
+
+import com.google.gerrit.k8s.operator.network.ambassador.dependent.CreateHostCondition;
+import com.google.gerrit.k8s.operator.network.ambassador.dependent.GerritClusterHost;
+import com.google.gerrit.k8s.operator.network.ambassador.dependent.GerritClusterMapping;
+import com.google.gerrit.k8s.operator.network.ambassador.dependent.GerritClusterMappingGETReplica;
+import com.google.gerrit.k8s.operator.network.ambassador.dependent.GerritClusterMappingPOSTReplica;
+import com.google.gerrit.k8s.operator.network.ambassador.dependent.GerritClusterMappingPrimary;
+import com.google.gerrit.k8s.operator.network.ambassador.dependent.GerritClusterMappingReceiver;
+import com.google.gerrit.k8s.operator.network.ambassador.dependent.GerritClusterMappingReceiverGET;
+import com.google.gerrit.k8s.operator.network.ambassador.dependent.GerritClusterTLSContext;
+import com.google.gerrit.k8s.operator.network.ambassador.dependent.LoadBalanceCondition;
+import com.google.gerrit.k8s.operator.network.ambassador.dependent.ReceiverMappingCondition;
+import com.google.gerrit.k8s.operator.network.ambassador.dependent.SingleMappingCondition;
+import com.google.gerrit.k8s.operator.network.ambassador.dependent.TLSContextCondition;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.network.GerritNetwork;
+import com.google.inject.Singleton;
+import io.getambassador.v2.Mapping;
+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.source.EventSource;
+import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Provides an Ambassador-based implementation for GerritNetworkReconciler.
+ *
+ * <p>Creates and manages Ambassador Custom Resources using the "managed dependent resources"
+ * approach in josdk. Since multiple dependent resources of the same type (`Mapping`) need to be
+ * created, "resource discriminators" are used for each of the different Mapping dependent
+ * resources.
+ *
+ * <p>Ambassador custom resource POJOs are generated via the `java-generator-maven-plugin` in the
+ * fabric8 project.
+ *
+ * <p>Mapping logic
+ *
+ * <p>The Mappings are created based on the composition of Gerrit instances in the GerritCluster.
+ *
+ * <p>There are three cases:
+ *
+ * <p>1. 0 Primary 1 Replica
+ *
+ * <p>Direct all traffic (read/write) to the Replica
+ *
+ * <p>2. 1 Primary 0 Replica
+ *
+ * <p>Direct all traffic (read/write) to the Primary
+ *
+ * <p>3. 1 Primary 1 Replica
+ *
+ * <p>Direct write traffic to Primary and read traffic to Replica. To capture this requirement,
+ * three different Mappings have to be created.
+ *
+ * <p>Note: git fetch/clone operations result in two HTTP requests to the git server. The first is
+ * of the form `GET /my-test-repo/info/refs?service=git-upload-pack` and the second is of the form
+ * `POST /my-test-repo/git-upload-pack`.
+ *
+ * <p>Note: git push operations result in two HTTP requests to the git server. The first is of the
+ * form `GET /my-test-repo/info/refs?service=git-receive-pack` and the second is of the form `POST
+ * /my-test-repo/git-receive-pack`.
+ *
+ * <p>If a Receiver is part of the GerritCluster, additional mappings are created such that all
+ * requests that the replication plugin sends to the `adminUrl` [1] are routed to the Receiver. This
+ * includes `git push` related `GET` and `POST` requests, and requests to the `/projects` REST API
+ * endpoints.
+ *
+ * <p>[1]
+ * https://gerrit.googlesource.com/plugins/replication/+/refs/heads/master/src/main/resources/Documentation/config.md
+ */
+@Singleton
+@ControllerConfiguration(
+    dependents = {
+      @Dependent(
+          name = GERRIT_MAPPING,
+          type = GerritClusterMapping.class,
+          // Cluster has only either Primary or Replica instance
+          reconcilePrecondition = SingleMappingCondition.class,
+          useEventSourceWithName = MAPPING_EVENT_SOURCE),
+      @Dependent(
+          name = GERRIT_MAPPING_POST_REPLICA,
+          type = GerritClusterMappingPOSTReplica.class,
+          // Cluster has both Primary and Replica instances
+          reconcilePrecondition = LoadBalanceCondition.class,
+          useEventSourceWithName = MAPPING_EVENT_SOURCE),
+      @Dependent(
+          name = GERRIT_MAPPING_GET_REPLICA,
+          type = GerritClusterMappingGETReplica.class,
+          reconcilePrecondition = LoadBalanceCondition.class,
+          useEventSourceWithName = MAPPING_EVENT_SOURCE),
+      @Dependent(
+          name = GERRIT_MAPPING_PRIMARY,
+          type = GerritClusterMappingPrimary.class,
+          reconcilePrecondition = LoadBalanceCondition.class,
+          useEventSourceWithName = MAPPING_EVENT_SOURCE),
+      @Dependent(
+          name = GERRIT_MAPPING_RECEIVER,
+          type = GerritClusterMappingReceiver.class,
+          reconcilePrecondition = ReceiverMappingCondition.class,
+          useEventSourceWithName = MAPPING_EVENT_SOURCE),
+      @Dependent(
+          name = GERRIT_MAPPING_RECEIVER_GET,
+          type = GerritClusterMappingReceiverGET.class,
+          reconcilePrecondition = ReceiverMappingCondition.class,
+          useEventSourceWithName = MAPPING_EVENT_SOURCE),
+      @Dependent(
+          name = GERRIT_TLS_CONTEXT,
+          type = GerritClusterTLSContext.class,
+          reconcilePrecondition = TLSContextCondition.class),
+      @Dependent(
+          name = GERRIT_HOST,
+          type = GerritClusterHost.class,
+          reconcilePrecondition = CreateHostCondition.class),
+    })
+public class GerritAmbassadorReconciler
+    implements Reconciler<GerritNetwork>, EventSourceInitializer<GerritNetwork> {
+
+  public static final String MAPPING_EVENT_SOURCE = "mapping-event-source";
+
+  // Because we have multiple dependent resources of the same type `Mapping`, we need to specify
+  // a named event source.
+  @Override
+  public Map<String, EventSource> prepareEventSources(EventSourceContext<GerritNetwork> context) {
+    InformerEventSource<Mapping, GerritNetwork> mappingEventSource =
+        new InformerEventSource<>(
+            InformerConfiguration.from(Mapping.class, context).build(), context);
+
+    Map<String, EventSource> eventSources = new HashMap<>();
+    eventSources.put(MAPPING_EVENT_SOURCE, mappingEventSource);
+    return eventSources;
+  }
+
+  @Override
+  public UpdateControl<GerritNetwork> reconcile(
+      GerritNetwork resource, Context<GerritNetwork> context) throws Exception {
+    return UpdateControl.noUpdate();
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/ambassador/dependent/AbstractAmbassadorDependentResource.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/ambassador/dependent/AbstractAmbassadorDependentResource.java
new file mode 100644
index 0000000..3cc8d4a
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/ambassador/dependent/AbstractAmbassadorDependentResource.java
@@ -0,0 +1,61 @@
+// Copyright (C) 2023 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.network.ambassador.dependent;
+
+import com.google.gerrit.k8s.operator.v1alpha.api.model.cluster.GerritCluster;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.network.GerritNetwork;
+import io.fabric8.kubernetes.api.model.HasMetadata;
+import io.fabric8.kubernetes.api.model.ObjectMeta;
+import io.fabric8.kubernetes.api.model.ObjectMetaBuilder;
+import io.getambassador.v2.MappingSpec;
+import io.getambassador.v2.MappingSpecBuilder;
+import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource;
+import java.util.List;
+
+public abstract class AbstractAmbassadorDependentResource<T extends HasMetadata>
+    extends CRUDKubernetesDependentResource<T, GerritNetwork> {
+
+  public AbstractAmbassadorDependentResource(Class<T> dependentResourceClass) {
+    super(dependentResourceClass);
+  }
+
+  public ObjectMeta getCommonMetadata(GerritNetwork gerritnetwork, String name, String className) {
+    ObjectMeta metadata =
+        new ObjectMetaBuilder()
+            .withName(name)
+            .withNamespace(gerritnetwork.getMetadata().getNamespace())
+            .withLabels(
+                GerritCluster.getLabels(gerritnetwork.getMetadata().getName(), name, className))
+            .build();
+    return metadata;
+  }
+
+  public MappingSpec getCommonSpec(GerritNetwork gerritnetwork, String serviceName) {
+    MappingSpec spec =
+        new MappingSpecBuilder()
+            .withAmbassadorId(getAmbassadorIds(gerritnetwork))
+            .withHost(gerritnetwork.getSpec().getIngress().getHost())
+            .withPrefix("/")
+            .withService(serviceName)
+            .withBypassAuth(true)
+            .withRewrite("") // important - so the prefix doesn't get overwritten to "/"
+            .build();
+    return spec;
+  }
+
+  public List<String> getAmbassadorIds(GerritNetwork gerritnetwork) {
+    return gerritnetwork.getSpec().getIngress().getAmbassador().getId();
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/ambassador/dependent/CreateHostCondition.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/ambassador/dependent/CreateHostCondition.java
new file mode 100644
index 0000000..e7f99ec
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/ambassador/dependent/CreateHostCondition.java
@@ -0,0 +1,34 @@
+// Copyright (C) 2023 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.network.ambassador.dependent;
+
+import com.google.gerrit.k8s.operator.v1alpha.api.model.network.GerritNetwork;
+import io.getambassador.v2.Mapping;
+import io.javaoperatorsdk.operator.api.reconciler.Context;
+import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource;
+import io.javaoperatorsdk.operator.processing.dependent.workflow.Condition;
+
+public class CreateHostCondition implements Condition<Mapping, GerritNetwork> {
+
+  @Override
+  public boolean isMet(
+      DependentResource<Mapping, GerritNetwork> dependentResource,
+      GerritNetwork gerritNetwork,
+      Context<GerritNetwork> context) {
+
+    return gerritNetwork.getSpec().getIngress().isEnabled()
+        && gerritNetwork.getSpec().getIngress().getAmbassador().getCreateHost();
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/ambassador/dependent/GerritClusterHost.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/ambassador/dependent/GerritClusterHost.java
new file mode 100644
index 0000000..5f916c4
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/ambassador/dependent/GerritClusterHost.java
@@ -0,0 +1,62 @@
+// Copyright (C) 2023 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.network.ambassador.dependent;
+
+import static com.google.gerrit.k8s.operator.network.ambassador.dependent.GerritClusterTLSContext.GERRIT_TLS_CONTEXT;
+
+import com.google.gerrit.k8s.operator.v1alpha.api.model.network.GerritNetwork;
+import io.getambassador.v2.Host;
+import io.getambassador.v2.HostBuilder;
+import io.getambassador.v2.hostspec.TlsContext;
+import io.getambassador.v2.hostspec.TlsSecret;
+import io.javaoperatorsdk.operator.api.reconciler.Context;
+
+public class GerritClusterHost extends AbstractAmbassadorDependentResource<Host> {
+
+  public static final String GERRIT_HOST = "gerrit-ambassador-host";
+
+  public GerritClusterHost() {
+    super(Host.class);
+  }
+
+  @Override
+  public Host desired(GerritNetwork gerritNetwork, Context<GerritNetwork> context) {
+
+    TlsSecret tlsSecret = null;
+    TlsContext tlsContext = null;
+
+    if (gerritNetwork.getSpec().getIngress().getTls().isEnabled()) {
+      tlsSecret = new TlsSecret();
+      tlsContext = new TlsContext();
+      tlsSecret.setName(gerritNetwork.getSpec().getIngress().getTls().getSecret());
+      tlsContext.setName(GERRIT_TLS_CONTEXT);
+    }
+
+    Host host =
+        new HostBuilder()
+            .withNewMetadataLike(
+                getCommonMetadata(gerritNetwork, GERRIT_HOST, this.getClass().getSimpleName()))
+            .endMetadata()
+            .withNewSpec()
+            .withAmbassadorId(getAmbassadorIds(gerritNetwork))
+            .withHostname(gerritNetwork.getSpec().getIngress().getHost())
+            .withTlsSecret(tlsSecret)
+            .withTlsContext(tlsContext)
+            .endSpec()
+            .build();
+
+    return host;
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/ambassador/dependent/GerritClusterMapping.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/ambassador/dependent/GerritClusterMapping.java
new file mode 100644
index 0000000..733be73
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/ambassador/dependent/GerritClusterMapping.java
@@ -0,0 +1,53 @@
+// Copyright (C) 2023 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.network.ambassador.dependent;
+
+import com.google.gerrit.k8s.operator.v1alpha.api.model.network.GerritNetwork;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.network.NetworkMemberWithSsh;
+import io.getambassador.v2.Mapping;
+import io.getambassador.v2.MappingBuilder;
+import io.javaoperatorsdk.operator.api.reconciler.Context;
+import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent;
+
+@KubernetesDependent(resourceDiscriminator = GerritClusterMappingDiscriminator.class)
+public class GerritClusterMapping extends AbstractAmbassadorDependentResource<Mapping>
+    implements MappingDependentResourceInterface {
+
+  public static final String GERRIT_MAPPING = "gerrit-mapping";
+
+  public GerritClusterMapping() {
+    super(Mapping.class);
+  }
+
+  @Override
+  public Mapping desired(GerritNetwork gerritNetwork, Context<GerritNetwork> context) {
+
+    // If only one Gerrit instance in GerritCluster, send all git-over-https requests to it
+    NetworkMemberWithSsh gerrit =
+        gerritNetwork.hasGerritReplica()
+            ? gerritNetwork.getSpec().getGerritReplica()
+            : gerritNetwork.getSpec().getPrimaryGerrit();
+    String serviceName = gerrit.getName() + ":" + gerrit.getHttpPort();
+    Mapping mapping =
+        new MappingBuilder()
+            .withNewMetadataLike(
+                getCommonMetadata(gerritNetwork, GERRIT_MAPPING, this.getClass().getSimpleName()))
+            .endMetadata()
+            .withNewSpecLike(getCommonSpec(gerritNetwork, serviceName))
+            .endSpec()
+            .build();
+    return mapping;
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/ambassador/dependent/GerritClusterMappingDiscriminator.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/ambassador/dependent/GerritClusterMappingDiscriminator.java
new file mode 100644
index 0000000..12d99c3
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/ambassador/dependent/GerritClusterMappingDiscriminator.java
@@ -0,0 +1,37 @@
+// Copyright (C) 2023 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.network.ambassador.dependent;
+
+import static com.google.gerrit.k8s.operator.network.ambassador.dependent.GerritClusterMapping.GERRIT_MAPPING;
+
+import com.google.gerrit.k8s.operator.v1alpha.api.model.network.GerritNetwork;
+import io.getambassador.v2.Mapping;
+import io.javaoperatorsdk.operator.api.reconciler.Context;
+import io.javaoperatorsdk.operator.api.reconciler.ResourceDiscriminator;
+import io.javaoperatorsdk.operator.processing.event.ResourceID;
+import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource;
+import java.util.Optional;
+
+public class GerritClusterMappingDiscriminator
+    implements ResourceDiscriminator<Mapping, GerritNetwork> {
+  @Override
+  public Optional<Mapping> distinguish(
+      Class<Mapping> resource, GerritNetwork network, Context<GerritNetwork> context) {
+    InformerEventSource<Mapping, GerritNetwork> ies =
+        (InformerEventSource<Mapping, GerritNetwork>)
+            context.eventSourceRetriever().getResourceEventSourceFor(Mapping.class);
+    return ies.get(new ResourceID(GERRIT_MAPPING, network.getMetadata().getNamespace()));
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/ambassador/dependent/GerritClusterMappingGETReplica.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/ambassador/dependent/GerritClusterMappingGETReplica.java
new file mode 100644
index 0000000..8fda99e
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/ambassador/dependent/GerritClusterMappingGETReplica.java
@@ -0,0 +1,68 @@
+// Copyright (C) 2023 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.network.ambassador.dependent;
+
+import static com.google.gerrit.k8s.operator.network.Constants.INFO_REFS_PATTERN;
+
+import com.google.gerrit.k8s.operator.v1alpha.api.model.network.GerritNetwork;
+import io.getambassador.v2.Mapping;
+import io.getambassador.v2.MappingBuilder;
+import io.javaoperatorsdk.operator.api.reconciler.Context;
+import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent;
+import java.util.HashMap;
+
+@KubernetesDependent(resourceDiscriminator = GerritClusterMappingGETReplicaDiscriminator.class)
+public class GerritClusterMappingGETReplica extends AbstractAmbassadorDependentResource<Mapping>
+    implements MappingDependentResourceInterface {
+
+  public static final String GERRIT_MAPPING_GET_REPLICA = "gerrit-mapping-get-replica";
+
+  public GerritClusterMappingGETReplica() {
+    super(Mapping.class);
+  }
+
+  @Override
+  public Mapping desired(GerritNetwork gerritNetwork, Context<GerritNetwork> context) {
+
+    String replicaServiceName =
+        gerritNetwork.getSpec().getGerritReplica().getName()
+            + ":"
+            + gerritNetwork.getSpec().getGerritReplica().getHttpPort();
+
+    // Send fetch/clone GET requests to the Replica
+    Mapping mapping =
+        new MappingBuilder()
+            .withNewMetadataLike(
+                getCommonMetadata(
+                    gerritNetwork, GERRIT_MAPPING_GET_REPLICA, this.getClass().getSimpleName()))
+            .endMetadata()
+            .withNewSpecLike(getCommonSpec(gerritNetwork, replicaServiceName))
+            .withNewV2QueryParameters()
+            .withAdditionalProperties(
+                new HashMap<String, Object>() {
+                  {
+                    put("service", "git-upload-pack");
+                  }
+                })
+            .endV2QueryParameters()
+            .withMethod("GET")
+            .withPrefix(INFO_REFS_PATTERN)
+            .withPrefixRegex(true)
+            .endSpec()
+            .build();
+
+    return mapping;
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/ambassador/dependent/GerritClusterMappingGETReplicaDiscriminator.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/ambassador/dependent/GerritClusterMappingGETReplicaDiscriminator.java
new file mode 100644
index 0000000..9a4da43
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/ambassador/dependent/GerritClusterMappingGETReplicaDiscriminator.java
@@ -0,0 +1,38 @@
+// Copyright (C) 2023 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.network.ambassador.dependent;
+
+import static com.google.gerrit.k8s.operator.network.ambassador.dependent.GerritClusterMappingGETReplica.GERRIT_MAPPING_GET_REPLICA;
+
+import com.google.gerrit.k8s.operator.v1alpha.api.model.network.GerritNetwork;
+import io.getambassador.v2.Mapping;
+import io.javaoperatorsdk.operator.api.reconciler.Context;
+import io.javaoperatorsdk.operator.api.reconciler.ResourceDiscriminator;
+import io.javaoperatorsdk.operator.processing.event.ResourceID;
+import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource;
+import java.util.Optional;
+
+public class GerritClusterMappingGETReplicaDiscriminator
+    implements ResourceDiscriminator<Mapping, GerritNetwork> {
+  @Override
+  public Optional<Mapping> distinguish(
+      Class<Mapping> resource, GerritNetwork network, Context<GerritNetwork> context) {
+    InformerEventSource<Mapping, GerritNetwork> ies =
+        (InformerEventSource<Mapping, GerritNetwork>)
+            context.eventSourceRetriever().getResourceEventSourceFor(Mapping.class);
+    return ies.get(
+        new ResourceID(GERRIT_MAPPING_GET_REPLICA, network.getMetadata().getNamespace()));
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/ambassador/dependent/GerritClusterMappingPOSTReplica.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/ambassador/dependent/GerritClusterMappingPOSTReplica.java
new file mode 100644
index 0000000..1779990
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/ambassador/dependent/GerritClusterMappingPOSTReplica.java
@@ -0,0 +1,58 @@
+// Copyright (C) 2023 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.network.ambassador.dependent;
+
+import static com.google.gerrit.k8s.operator.network.Constants.UPLOAD_PACK_URL_PATTERN;
+
+import com.google.gerrit.k8s.operator.v1alpha.api.model.network.GerritNetwork;
+import io.getambassador.v2.Mapping;
+import io.getambassador.v2.MappingBuilder;
+import io.javaoperatorsdk.operator.api.reconciler.Context;
+import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent;
+
+@KubernetesDependent(resourceDiscriminator = GerritClusterMappingPOSTReplicaDiscriminator.class)
+public class GerritClusterMappingPOSTReplica extends AbstractAmbassadorDependentResource<Mapping>
+    implements MappingDependentResourceInterface {
+
+  public static final String GERRIT_MAPPING_POST_REPLICA = "gerrit-mapping-post-replica";
+
+  public GerritClusterMappingPOSTReplica() {
+    super(Mapping.class);
+  }
+
+  @Override
+  public Mapping desired(GerritNetwork gerritNetwork, Context<GerritNetwork> context) {
+
+    String replicaServiceName =
+        gerritNetwork.getSpec().getGerritReplica().getName()
+            + ":"
+            + gerritNetwork.getSpec().getGerritReplica().getHttpPort();
+
+    // Send fetch/clone POST requests to the Replica
+    Mapping mapping =
+        new MappingBuilder()
+            .withNewMetadataLike(
+                getCommonMetadata(
+                    gerritNetwork, GERRIT_MAPPING_POST_REPLICA, this.getClass().getSimpleName()))
+            .endMetadata()
+            .withNewSpecLike(getCommonSpec(gerritNetwork, replicaServiceName))
+            .withPrefix(UPLOAD_PACK_URL_PATTERN)
+            .withPrefixRegex(true)
+            .withMethod("POST")
+            .endSpec()
+            .build();
+    return mapping;
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/ambassador/dependent/GerritClusterMappingPOSTReplicaDiscriminator.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/ambassador/dependent/GerritClusterMappingPOSTReplicaDiscriminator.java
new file mode 100644
index 0000000..a1c02c8
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/ambassador/dependent/GerritClusterMappingPOSTReplicaDiscriminator.java
@@ -0,0 +1,38 @@
+// Copyright (C) 2023 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.network.ambassador.dependent;
+
+import static com.google.gerrit.k8s.operator.network.ambassador.dependent.GerritClusterMappingPOSTReplica.GERRIT_MAPPING_POST_REPLICA;
+
+import com.google.gerrit.k8s.operator.v1alpha.api.model.network.GerritNetwork;
+import io.getambassador.v2.Mapping;
+import io.javaoperatorsdk.operator.api.reconciler.Context;
+import io.javaoperatorsdk.operator.api.reconciler.ResourceDiscriminator;
+import io.javaoperatorsdk.operator.processing.event.ResourceID;
+import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource;
+import java.util.Optional;
+
+public class GerritClusterMappingPOSTReplicaDiscriminator
+    implements ResourceDiscriminator<Mapping, GerritNetwork> {
+  @Override
+  public Optional<Mapping> distinguish(
+      Class<Mapping> resource, GerritNetwork network, Context<GerritNetwork> context) {
+    InformerEventSource<Mapping, GerritNetwork> ies =
+        (InformerEventSource<Mapping, GerritNetwork>)
+            context.eventSourceRetriever().getResourceEventSourceFor(Mapping.class);
+    return ies.get(
+        new ResourceID(GERRIT_MAPPING_POST_REPLICA, network.getMetadata().getNamespace()));
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/ambassador/dependent/GerritClusterMappingPrimary.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/ambassador/dependent/GerritClusterMappingPrimary.java
new file mode 100644
index 0000000..f62b74a
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/ambassador/dependent/GerritClusterMappingPrimary.java
@@ -0,0 +1,55 @@
+// Copyright (C) 2023 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.network.ambassador.dependent;
+
+import com.google.gerrit.k8s.operator.v1alpha.api.model.network.GerritNetwork;
+import io.getambassador.v2.Mapping;
+import io.getambassador.v2.MappingBuilder;
+import io.javaoperatorsdk.operator.api.reconciler.Context;
+import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent;
+
+@KubernetesDependent(resourceDiscriminator = GerritClusterMappingPrimaryDiscriminator.class)
+public class GerritClusterMappingPrimary extends AbstractAmbassadorDependentResource<Mapping>
+    implements MappingDependentResourceInterface {
+
+  public static final String GERRIT_MAPPING_PRIMARY = "gerrit-mapping-primary";
+
+  public GerritClusterMappingPrimary() {
+    super(Mapping.class);
+  }
+
+  @Override
+  public Mapping desired(GerritNetwork gerritNetwork, Context<GerritNetwork> context) {
+
+    String primaryServiceName =
+        gerritNetwork.getSpec().getPrimaryGerrit().getName()
+            + ":"
+            + gerritNetwork.getSpec().getPrimaryGerrit().getHttpPort();
+
+    // Send all write traffic (non git fetch/clone traffic) to the Primary.
+    // Emissary evaluates more constrained Mappings first.
+    Mapping mapping =
+        new MappingBuilder()
+            .withNewMetadataLike(
+                getCommonMetadata(
+                    gerritNetwork, GERRIT_MAPPING_PRIMARY, this.getClass().getSimpleName()))
+            .endMetadata()
+            .withNewSpecLike(getCommonSpec(gerritNetwork, primaryServiceName))
+            .endSpec()
+            .build();
+
+    return mapping;
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/ambassador/dependent/GerritClusterMappingPrimaryDiscriminator.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/ambassador/dependent/GerritClusterMappingPrimaryDiscriminator.java
new file mode 100644
index 0000000..0d38c69
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/ambassador/dependent/GerritClusterMappingPrimaryDiscriminator.java
@@ -0,0 +1,37 @@
+// Copyright (C) 2023 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.network.ambassador.dependent;
+
+import static com.google.gerrit.k8s.operator.network.ambassador.dependent.GerritClusterMappingPrimary.GERRIT_MAPPING_PRIMARY;
+
+import com.google.gerrit.k8s.operator.v1alpha.api.model.network.GerritNetwork;
+import io.getambassador.v2.Mapping;
+import io.javaoperatorsdk.operator.api.reconciler.Context;
+import io.javaoperatorsdk.operator.api.reconciler.ResourceDiscriminator;
+import io.javaoperatorsdk.operator.processing.event.ResourceID;
+import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource;
+import java.util.Optional;
+
+public class GerritClusterMappingPrimaryDiscriminator
+    implements ResourceDiscriminator<Mapping, GerritNetwork> {
+  @Override
+  public Optional<Mapping> distinguish(
+      Class<Mapping> resource, GerritNetwork network, Context<GerritNetwork> context) {
+    InformerEventSource<Mapping, GerritNetwork> ies =
+        (InformerEventSource<Mapping, GerritNetwork>)
+            context.eventSourceRetriever().getResourceEventSourceFor(Mapping.class);
+    return ies.get(new ResourceID(GERRIT_MAPPING_PRIMARY, network.getMetadata().getNamespace()));
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/ambassador/dependent/GerritClusterMappingReceiver.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/ambassador/dependent/GerritClusterMappingReceiver.java
new file mode 100644
index 0000000..2d50b65
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/ambassador/dependent/GerritClusterMappingReceiver.java
@@ -0,0 +1,58 @@
+// Copyright (C) 2023 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.network.ambassador.dependent;
+
+import static com.google.gerrit.k8s.operator.network.Constants.PROJECTS_URL_PATTERN;
+import static com.google.gerrit.k8s.operator.network.Constants.RECEIVE_PACK_URL_PATTERN;
+
+import com.google.gerrit.k8s.operator.receiver.dependent.ReceiverService;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.network.GerritNetwork;
+import io.getambassador.v2.Mapping;
+import io.getambassador.v2.MappingBuilder;
+import io.javaoperatorsdk.operator.api.reconciler.Context;
+import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent;
+
+@KubernetesDependent(resourceDiscriminator = GerritClusterMappingReceiverDiscriminator.class)
+public class GerritClusterMappingReceiver extends AbstractAmbassadorDependentResource<Mapping>
+    implements MappingDependentResourceInterface {
+
+  public static final String GERRIT_MAPPING_RECEIVER = "gerrit-mapping-receiver";
+
+  public GerritClusterMappingReceiver() {
+    super(Mapping.class);
+  }
+
+  @Override
+  public Mapping desired(GerritNetwork gerritNetwork, Context<GerritNetwork> context) {
+
+    String receiverServiceName =
+        ReceiverService.getName(gerritNetwork.getSpec().getReceiver().getName())
+            + ":"
+            + gerritNetwork.getSpec().getReceiver().getHttpPort();
+
+    Mapping mapping =
+        new MappingBuilder()
+            .withNewMetadataLike(
+                getCommonMetadata(
+                    gerritNetwork, GERRIT_MAPPING_RECEIVER, this.getClass().getSimpleName()))
+            .endMetadata()
+            .withNewSpecLike(getCommonSpec(gerritNetwork, receiverServiceName))
+            .withPrefix(PROJECTS_URL_PATTERN + "|" + RECEIVE_PACK_URL_PATTERN)
+            .withPrefixRegex(true)
+            .endSpec()
+            .build();
+    return mapping;
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/ambassador/dependent/GerritClusterMappingReceiverDiscriminator.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/ambassador/dependent/GerritClusterMappingReceiverDiscriminator.java
new file mode 100644
index 0000000..a31e905
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/ambassador/dependent/GerritClusterMappingReceiverDiscriminator.java
@@ -0,0 +1,37 @@
+// Copyright (C) 2023 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.network.ambassador.dependent;
+
+import static com.google.gerrit.k8s.operator.network.ambassador.dependent.GerritClusterMappingReceiver.GERRIT_MAPPING_RECEIVER;
+
+import com.google.gerrit.k8s.operator.v1alpha.api.model.network.GerritNetwork;
+import io.getambassador.v2.Mapping;
+import io.javaoperatorsdk.operator.api.reconciler.Context;
+import io.javaoperatorsdk.operator.api.reconciler.ResourceDiscriminator;
+import io.javaoperatorsdk.operator.processing.event.ResourceID;
+import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource;
+import java.util.Optional;
+
+public class GerritClusterMappingReceiverDiscriminator
+    implements ResourceDiscriminator<Mapping, GerritNetwork> {
+  @Override
+  public Optional<Mapping> distinguish(
+      Class<Mapping> resource, GerritNetwork network, Context<GerritNetwork> context) {
+    InformerEventSource<Mapping, GerritNetwork> ies =
+        (InformerEventSource<Mapping, GerritNetwork>)
+            context.eventSourceRetriever().getResourceEventSourceFor(Mapping.class);
+    return ies.get(new ResourceID(GERRIT_MAPPING_RECEIVER, network.getMetadata().getNamespace()));
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/ambassador/dependent/GerritClusterMappingReceiverGET.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/ambassador/dependent/GerritClusterMappingReceiverGET.java
new file mode 100644
index 0000000..aadd9dc
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/ambassador/dependent/GerritClusterMappingReceiverGET.java
@@ -0,0 +1,67 @@
+// Copyright (C) 2023 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.network.ambassador.dependent;
+
+import static com.google.gerrit.k8s.operator.network.Constants.INFO_REFS_PATTERN;
+
+import com.google.gerrit.k8s.operator.receiver.dependent.ReceiverService;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.network.GerritNetwork;
+import io.getambassador.v2.Mapping;
+import io.getambassador.v2.MappingBuilder;
+import io.javaoperatorsdk.operator.api.reconciler.Context;
+import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent;
+import java.util.HashMap;
+
+@KubernetesDependent(resourceDiscriminator = GerritClusterMappingReceiverGETDiscriminator.class)
+public class GerritClusterMappingReceiverGET extends AbstractAmbassadorDependentResource<Mapping>
+    implements MappingDependentResourceInterface {
+
+  public static final String GERRIT_MAPPING_RECEIVER_GET = "gerrit-mapping-receiver-get";
+
+  public GerritClusterMappingReceiverGET() {
+    super(Mapping.class);
+  }
+
+  @Override
+  public Mapping desired(GerritNetwork gerritNetwork, Context<GerritNetwork> context) {
+
+    String receiverServiceName =
+        ReceiverService.getName(gerritNetwork.getSpec().getReceiver().getName())
+            + ":"
+            + gerritNetwork.getSpec().getReceiver().getHttpPort();
+
+    Mapping mapping =
+        new MappingBuilder()
+            .withNewMetadataLike(
+                getCommonMetadata(
+                    gerritNetwork, GERRIT_MAPPING_RECEIVER_GET, this.getClass().getSimpleName()))
+            .endMetadata()
+            .withNewSpecLike(getCommonSpec(gerritNetwork, receiverServiceName))
+            .withNewV2QueryParameters()
+            .withAdditionalProperties(
+                new HashMap<String, Object>() {
+                  {
+                    put("service", "git-receive-pack");
+                  }
+                })
+            .endV2QueryParameters()
+            .withMethod("GET")
+            .withPrefix(INFO_REFS_PATTERN)
+            .withPrefixRegex(true)
+            .endSpec()
+            .build();
+    return mapping;
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/ambassador/dependent/GerritClusterMappingReceiverGETDiscriminator.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/ambassador/dependent/GerritClusterMappingReceiverGETDiscriminator.java
new file mode 100644
index 0000000..24cf7f8
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/ambassador/dependent/GerritClusterMappingReceiverGETDiscriminator.java
@@ -0,0 +1,38 @@
+// Copyright (C) 2023 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.network.ambassador.dependent;
+
+import static com.google.gerrit.k8s.operator.network.ambassador.dependent.GerritClusterMappingReceiverGET.GERRIT_MAPPING_RECEIVER_GET;
+
+import com.google.gerrit.k8s.operator.v1alpha.api.model.network.GerritNetwork;
+import io.getambassador.v2.Mapping;
+import io.javaoperatorsdk.operator.api.reconciler.Context;
+import io.javaoperatorsdk.operator.api.reconciler.ResourceDiscriminator;
+import io.javaoperatorsdk.operator.processing.event.ResourceID;
+import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource;
+import java.util.Optional;
+
+public class GerritClusterMappingReceiverGETDiscriminator
+    implements ResourceDiscriminator<Mapping, GerritNetwork> {
+  @Override
+  public Optional<Mapping> distinguish(
+      Class<Mapping> resource, GerritNetwork network, Context<GerritNetwork> context) {
+    InformerEventSource<Mapping, GerritNetwork> ies =
+        (InformerEventSource<Mapping, GerritNetwork>)
+            context.eventSourceRetriever().getResourceEventSourceFor(Mapping.class);
+    return ies.get(
+        new ResourceID(GERRIT_MAPPING_RECEIVER_GET, network.getMetadata().getNamespace()));
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/ambassador/dependent/GerritClusterTLSContext.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/ambassador/dependent/GerritClusterTLSContext.java
new file mode 100644
index 0000000..7cae8da
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/ambassador/dependent/GerritClusterTLSContext.java
@@ -0,0 +1,48 @@
+// Copyright (C) 2023 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.network.ambassador.dependent;
+
+import com.google.gerrit.k8s.operator.v1alpha.api.model.network.GerritNetwork;
+import io.getambassador.v2.TLSContext;
+import io.getambassador.v2.TLSContextBuilder;
+import io.javaoperatorsdk.operator.api.reconciler.Context;
+import java.util.List;
+
+public class GerritClusterTLSContext extends AbstractAmbassadorDependentResource<TLSContext> {
+
+  public static final String GERRIT_TLS_CONTEXT = "gerrit-tls-context";
+
+  public GerritClusterTLSContext() {
+    super(TLSContext.class);
+  }
+
+  @Override
+  protected TLSContext desired(GerritNetwork gerritNetwork, Context<GerritNetwork> context) {
+    TLSContext tlsContext =
+        new TLSContextBuilder()
+            .withNewMetadataLike(
+                getCommonMetadata(
+                    gerritNetwork, GERRIT_TLS_CONTEXT, this.getClass().getSimpleName()))
+            .endMetadata()
+            .withNewSpec()
+            .withAmbassadorId(getAmbassadorIds(gerritNetwork))
+            .withSecret(gerritNetwork.getSpec().getIngress().getTls().getSecret())
+            .withHosts(List.of(gerritNetwork.getSpec().getIngress().getHost()))
+            .withSecretNamespacing(true)
+            .endSpec()
+            .build();
+    return tlsContext;
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/ambassador/dependent/LoadBalanceCondition.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/ambassador/dependent/LoadBalanceCondition.java
new file mode 100644
index 0000000..ea9066a
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/ambassador/dependent/LoadBalanceCondition.java
@@ -0,0 +1,35 @@
+// Copyright (C) 2023 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.network.ambassador.dependent;
+
+import com.google.gerrit.k8s.operator.v1alpha.api.model.network.GerritNetwork;
+import io.getambassador.v2.Mapping;
+import io.javaoperatorsdk.operator.api.reconciler.Context;
+import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource;
+import io.javaoperatorsdk.operator.processing.dependent.workflow.Condition;
+
+public class LoadBalanceCondition implements Condition<Mapping, GerritNetwork> {
+
+  @Override
+  public boolean isMet(
+      DependentResource<Mapping, GerritNetwork> dependentResource,
+      GerritNetwork gerritNetwork,
+      Context<GerritNetwork> context) {
+
+    return gerritNetwork.getSpec().getIngress().isEnabled()
+        && gerritNetwork.hasPrimaryGerrit()
+        && gerritNetwork.hasGerritReplica();
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/ambassador/dependent/MappingDependentResourceInterface.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/ambassador/dependent/MappingDependentResourceInterface.java
new file mode 100644
index 0000000..dc27428
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/ambassador/dependent/MappingDependentResourceInterface.java
@@ -0,0 +1,23 @@
+// Copyright (C) 2023 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.network.ambassador.dependent;
+
+import com.google.gerrit.k8s.operator.v1alpha.api.model.network.GerritNetwork;
+import io.getambassador.v2.Mapping;
+import io.javaoperatorsdk.operator.api.reconciler.Context;
+
+public interface MappingDependentResourceInterface {
+  public Mapping desired(GerritNetwork gerritNetwork, Context<GerritNetwork> context);
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/ambassador/dependent/ReceiverMappingCondition.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/ambassador/dependent/ReceiverMappingCondition.java
new file mode 100644
index 0000000..a83f984
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/ambassador/dependent/ReceiverMappingCondition.java
@@ -0,0 +1,33 @@
+// Copyright (C) 2023 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.network.ambassador.dependent;
+
+import com.google.gerrit.k8s.operator.v1alpha.api.model.network.GerritNetwork;
+import io.getambassador.v2.Mapping;
+import io.javaoperatorsdk.operator.api.reconciler.Context;
+import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource;
+import io.javaoperatorsdk.operator.processing.dependent.workflow.Condition;
+
+public class ReceiverMappingCondition implements Condition<Mapping, GerritNetwork> {
+
+  @Override
+  public boolean isMet(
+      DependentResource<Mapping, GerritNetwork> dependentResource,
+      GerritNetwork gerritNetwork,
+      Context<GerritNetwork> context) {
+
+    return gerritNetwork.getSpec().getIngress().isEnabled() && gerritNetwork.hasReceiver();
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/ambassador/dependent/SingleMappingCondition.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/ambassador/dependent/SingleMappingCondition.java
new file mode 100644
index 0000000..52a1f2c
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/ambassador/dependent/SingleMappingCondition.java
@@ -0,0 +1,34 @@
+// Copyright (C) 2023 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.network.ambassador.dependent;
+
+import com.google.gerrit.k8s.operator.v1alpha.api.model.network.GerritNetwork;
+import io.getambassador.v2.Mapping;
+import io.javaoperatorsdk.operator.api.reconciler.Context;
+import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource;
+import io.javaoperatorsdk.operator.processing.dependent.workflow.Condition;
+
+public class SingleMappingCondition implements Condition<Mapping, GerritNetwork> {
+
+  @Override
+  public boolean isMet(
+      DependentResource<Mapping, GerritNetwork> dependentResource,
+      GerritNetwork gerritNetwork,
+      Context<GerritNetwork> context) {
+
+    return gerritNetwork.getSpec().getIngress().isEnabled()
+        && (gerritNetwork.hasPrimaryGerrit() ^ gerritNetwork.hasGerritReplica());
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/ambassador/dependent/TLSContextCondition.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/ambassador/dependent/TLSContextCondition.java
new file mode 100644
index 0000000..053c559
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/ambassador/dependent/TLSContextCondition.java
@@ -0,0 +1,34 @@
+// Copyright (C) 2023 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.network.ambassador.dependent;
+
+import com.google.gerrit.k8s.operator.v1alpha.api.model.network.GerritNetwork;
+import io.getambassador.v2.Mapping;
+import io.javaoperatorsdk.operator.api.reconciler.Context;
+import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource;
+import io.javaoperatorsdk.operator.processing.dependent.workflow.Condition;
+
+public class TLSContextCondition implements Condition<Mapping, GerritNetwork> {
+
+  @Override
+  public boolean isMet(
+      DependentResource<Mapping, GerritNetwork> dependentResource,
+      GerritNetwork gerritNetwork,
+      Context<GerritNetwork> context) {
+
+    return gerritNetwork.getSpec().getIngress().isEnabled()
+        && gerritNetwork.getSpec().getIngress().getTls().isEnabled();
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/ingress/GerritIngressReconciler.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/ingress/GerritIngressReconciler.java
new file mode 100644
index 0000000..30c90a9
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/ingress/GerritIngressReconciler.java
@@ -0,0 +1,42 @@
+// Copyright (C) 2023 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.network.ingress;
+
+import com.google.gerrit.k8s.operator.network.GerritClusterIngressCondition;
+import com.google.gerrit.k8s.operator.network.ingress.dependent.GerritClusterIngress;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.network.GerritNetwork;
+import com.google.inject.Singleton;
+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;
+
+@Singleton
+@ControllerConfiguration(
+    dependents = {
+      @Dependent(
+          name = "gerrit-ingress",
+          type = GerritClusterIngress.class,
+          reconcilePrecondition = GerritClusterIngressCondition.class)
+    })
+public class GerritIngressReconciler implements Reconciler<GerritNetwork> {
+
+  @Override
+  public UpdateControl<GerritNetwork> reconcile(
+      GerritNetwork resource, Context<GerritNetwork> context) throws Exception {
+    return UpdateControl.noUpdate();
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/ingress/dependent/GerritClusterIngress.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/ingress/dependent/GerritClusterIngress.java
new file mode 100644
index 0000000..a62e298
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/ingress/dependent/GerritClusterIngress.java
@@ -0,0 +1,247 @@
+// 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.network.ingress.dependent;
+
+import static com.google.gerrit.k8s.operator.v1alpha.api.model.network.GerritNetwork.SESSION_COOKIE_NAME;
+
+import com.google.gerrit.k8s.operator.gerrit.dependent.GerritService;
+import com.google.gerrit.k8s.operator.receiver.dependent.ReceiverService;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.cluster.GerritCluster;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.network.GerritNetwork;
+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;
+import io.fabric8.kubernetes.api.model.networking.v1.IngressBuilder;
+import io.fabric8.kubernetes.api.model.networking.v1.IngressRule;
+import io.fabric8.kubernetes.api.model.networking.v1.IngressRuleBuilder;
+import io.fabric8.kubernetes.api.model.networking.v1.IngressSpecBuilder;
+import io.fabric8.kubernetes.api.model.networking.v1.IngressTLS;
+import io.fabric8.kubernetes.api.model.networking.v1.IngressTLSBuilder;
+import io.fabric8.kubernetes.api.model.networking.v1.ServiceBackendPort;
+import io.fabric8.kubernetes.api.model.networking.v1.ServiceBackendPortBuilder;
+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.HashMap;
+import java.util.List;
+import java.util.Map;
+
+@KubernetesDependent
+public class GerritClusterIngress extends CRUDKubernetesDependentResource<Ingress, GerritNetwork> {
+  private static final String UPLOAD_PACK_URL_PATTERN = "/.*/git-upload-pack";
+  private static final String RECEIVE_PACK_URL_PATTERN = "/.*/git-receive-pack";
+  public static final String INGRESS_NAME = "gerrit-ingress";
+
+  public GerritClusterIngress() {
+    super(Ingress.class);
+  }
+
+  @Override
+  protected Ingress desired(GerritNetwork gerritNetwork, Context<GerritNetwork> context) {
+    IngressSpecBuilder ingressSpecBuilder =
+        new IngressSpecBuilder().withRules(getIngressRule(gerritNetwork));
+    if (gerritNetwork.getSpec().getIngress().getTls().isEnabled()) {
+      ingressSpecBuilder.withTls(getIngressTLS(gerritNetwork));
+    }
+
+    Ingress gerritIngress =
+        new IngressBuilder()
+            .withNewMetadata()
+            .withName("gerrit-ingress")
+            .withNamespace(gerritNetwork.getMetadata().getNamespace())
+            .withLabels(
+                GerritCluster.getLabels(
+                    gerritNetwork.getMetadata().getName(),
+                    "gerrit-ingress",
+                    this.getClass().getSimpleName()))
+            .withAnnotations(getAnnotations(gerritNetwork))
+            .endMetadata()
+            .withSpec(ingressSpecBuilder.build())
+            .build();
+
+    return gerritIngress;
+  }
+
+  private Map<String, String> getAnnotations(GerritNetwork gerritNetwork) {
+    Map<String, String> annotations = gerritNetwork.getSpec().getIngress().getAnnotations();
+    if (annotations == null) {
+      annotations = new HashMap<>();
+    }
+    annotations.put("nginx.ingress.kubernetes.io/use-regex", "true");
+    annotations.put("kubernetes.io/ingress.class", "nginx");
+
+    String configSnippet = "";
+    if (gerritNetwork.hasPrimaryGerrit() && gerritNetwork.hasGerritReplica()) {
+      String svcName = GerritService.getName(gerritNetwork.getSpec().getGerritReplica().getName());
+      configSnippet =
+          createNginxConfigSnippet(
+              "service=git-upload-pack", gerritNetwork.getMetadata().getNamespace(), svcName);
+    }
+    if (gerritNetwork.hasReceiver()) {
+      String svcName = ReceiverService.getName(gerritNetwork.getSpec().getReceiver().getName());
+      configSnippet =
+          createNginxConfigSnippet(
+              "service=git-receive-pack", gerritNetwork.getMetadata().getNamespace(), svcName);
+    }
+    if (!configSnippet.isBlank()) {
+      annotations.put("nginx.ingress.kubernetes.io/configuration-snippet", configSnippet);
+    }
+
+    annotations.put("nginx.ingress.kubernetes.io/affinity", "cookie");
+    annotations.put("nginx.ingress.kubernetes.io/session-cookie-name", SESSION_COOKIE_NAME);
+    annotations.put("nginx.ingress.kubernetes.io/session-cookie-path", "/");
+    annotations.put("nginx.ingress.kubernetes.io/session-cookie-max-age", "60");
+    annotations.put("nginx.ingress.kubernetes.io/session-cookie-expires", "60");
+
+    return annotations;
+  }
+
+  /**
+   * Creates a config snippet for the Nginx Ingress Controller [1]. This snippet will configure
+   * Nginx to route the request based on the `service` query parameter.
+   *
+   * <p>If it is set to `git-upload-pack` it will route the request to the provided service.
+   *
+   * <p>[1]https://docs.nginx.com/nginx-ingress-controller/configuration/ingress-resources/advanced-configuration-with-snippets/
+   *
+   * @param namespace Namespace of the destination service.
+   * @param svcName Name of the destination service.
+   * @return configuration snippet
+   */
+  private String createNginxConfigSnippet(String queryParam, String namespace, String svcName) {
+    StringBuilder configSnippet = new StringBuilder();
+    configSnippet.append("if ($args ~ ");
+    configSnippet.append(queryParam);
+    configSnippet.append("){");
+    configSnippet.append("\n");
+    configSnippet.append("  set $proxy_upstream_name \"");
+    configSnippet.append(namespace);
+    configSnippet.append("-");
+    configSnippet.append(svcName);
+    configSnippet.append("-");
+    configSnippet.append(GerritService.HTTP_PORT_NAME);
+    configSnippet.append("\";\n");
+    configSnippet.append("  set $proxy_host $proxy_upstream_name;");
+    configSnippet.append("\n");
+    configSnippet.append("  set $service_name \"");
+    configSnippet.append(svcName);
+    configSnippet.append("\";\n}");
+    return configSnippet.toString();
+  }
+
+  private IngressTLS getIngressTLS(GerritNetwork gerritNetwork) {
+    if (gerritNetwork.getSpec().getIngress().getTls().isEnabled()) {
+      return new IngressTLSBuilder()
+          .withHosts(gerritNetwork.getSpec().getIngress().getHost())
+          .withSecretName(gerritNetwork.getSpec().getIngress().getTls().getSecret())
+          .build();
+    }
+    return null;
+  }
+
+  private IngressRule getIngressRule(GerritNetwork gerritNetwork) {
+    List<HTTPIngressPath> ingressPaths = new ArrayList<>();
+    if (gerritNetwork.hasReceiver()) {
+      ingressPaths.addAll(getReceiverIngressPaths(gerritNetwork));
+    }
+    if (gerritNetwork.hasGerrits()) {
+      ingressPaths.addAll(getGerritHTTPIngressPaths(gerritNetwork));
+    }
+
+    if (ingressPaths.isEmpty()) {
+      throw new IllegalStateException(
+          "Failed to create Ingress: No Receiver or Gerrit in GerritCluster.");
+    }
+
+    return new IngressRuleBuilder()
+        .withHost(gerritNetwork.getSpec().getIngress().getHost())
+        .withNewHttp()
+        .withPaths(ingressPaths)
+        .endHttp()
+        .build();
+  }
+
+  private List<HTTPIngressPath> getGerritHTTPIngressPaths(GerritNetwork gerritNetwork) {
+    ServiceBackendPort port =
+        new ServiceBackendPortBuilder().withName(GerritService.HTTP_PORT_NAME).build();
+
+    List<HTTPIngressPath> paths = new ArrayList<>();
+    // Order matters, since routing rules will be applied in order!
+    if (!gerritNetwork.hasPrimaryGerrit() && gerritNetwork.hasGerritReplica()) {
+      paths.add(
+          new HTTPIngressPathBuilder()
+              .withPathType("Prefix")
+              .withPath("/")
+              .withNewBackend()
+              .withNewService()
+              .withName(GerritService.getName(gerritNetwork.getSpec().getGerritReplica().getName()))
+              .withPort(port)
+              .endService()
+              .endBackend()
+              .build());
+      return paths;
+    }
+    if (gerritNetwork.hasGerritReplica()) {
+      paths.add(
+          new HTTPIngressPathBuilder()
+              .withPathType("Prefix")
+              .withPath(UPLOAD_PACK_URL_PATTERN)
+              .withNewBackend()
+              .withNewService()
+              .withName(GerritService.getName(gerritNetwork.getSpec().getGerritReplica().getName()))
+              .withPort(port)
+              .endService()
+              .endBackend()
+              .build());
+    }
+    if (gerritNetwork.hasPrimaryGerrit()) {
+      paths.add(
+          new HTTPIngressPathBuilder()
+              .withPathType("Prefix")
+              .withPath("/")
+              .withNewBackend()
+              .withNewService()
+              .withName(GerritService.getName(gerritNetwork.getSpec().getPrimaryGerrit().getName()))
+              .withPort(port)
+              .endService()
+              .endBackend()
+              .build());
+    }
+    return paths;
+  }
+
+  private List<HTTPIngressPath> getReceiverIngressPaths(GerritNetwork gerritNetwork) {
+    String svcName = ReceiverService.getName(gerritNetwork.getSpec().getReceiver().getName());
+    List<HTTPIngressPath> paths = new ArrayList<>();
+    ServiceBackendPort port =
+        new ServiceBackendPortBuilder().withName(ReceiverService.HTTP_PORT_NAME).build();
+
+    for (String path : List.of("/a/projects", RECEIVE_PACK_URL_PATTERN)) {
+      paths.add(
+          new HTTPIngressPathBuilder()
+              .withPathType("Prefix")
+              .withPath(path)
+              .withNewBackend()
+              .withNewService()
+              .withName(svcName)
+              .withPort(port)
+              .endService()
+              .endBackend()
+              .build());
+    }
+    return paths;
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/istio/GerritIstioReconciler.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/istio/GerritIstioReconciler.java
new file mode 100644
index 0000000..4d12941
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/istio/GerritIstioReconciler.java
@@ -0,0 +1,89 @@
+// Copyright (C) 2023 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.network.istio;
+
+import static com.google.gerrit.k8s.operator.network.istio.GerritIstioReconciler.ISTIO_DESTINATION_RULE_EVENT_SOURCE;
+import static com.google.gerrit.k8s.operator.network.istio.GerritIstioReconciler.ISTIO_VIRTUAL_SERVICE_EVENT_SOURCE;
+
+import com.google.gerrit.k8s.operator.network.GerritClusterIngressCondition;
+import com.google.gerrit.k8s.operator.network.istio.dependent.GerritClusterIstioGateway;
+import com.google.gerrit.k8s.operator.network.istio.dependent.GerritIstioCondition;
+import com.google.gerrit.k8s.operator.network.istio.dependent.GerritIstioDestinationRule;
+import com.google.gerrit.k8s.operator.network.istio.dependent.GerritIstioVirtualService;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.network.GerritNetwork;
+import com.google.inject.Singleton;
+import io.fabric8.istio.api.networking.v1beta1.DestinationRule;
+import io.fabric8.istio.api.networking.v1beta1.VirtualService;
+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.source.EventSource;
+import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource;
+import java.util.HashMap;
+import java.util.Map;
+
+@Singleton
+@ControllerConfiguration(
+    dependents = {
+      @Dependent(
+          name = "gerrit-destination-rules",
+          type = GerritIstioDestinationRule.class,
+          reconcilePrecondition = GerritIstioCondition.class,
+          useEventSourceWithName = ISTIO_DESTINATION_RULE_EVENT_SOURCE),
+      @Dependent(
+          name = "gerrit-istio-gateway",
+          type = GerritClusterIstioGateway.class,
+          reconcilePrecondition = GerritClusterIngressCondition.class),
+      @Dependent(
+          name = "gerrit-istio-virtual-service",
+          type = GerritIstioVirtualService.class,
+          reconcilePrecondition = GerritIstioCondition.class,
+          dependsOn = {"gerrit-istio-gateway"},
+          useEventSourceWithName = ISTIO_VIRTUAL_SERVICE_EVENT_SOURCE),
+    })
+public class GerritIstioReconciler
+    implements Reconciler<GerritNetwork>, EventSourceInitializer<GerritNetwork> {
+  public static final String ISTIO_DESTINATION_RULE_EVENT_SOURCE =
+      "gerrit-cluster-istio-destination-rule";
+  public static final String ISTIO_VIRTUAL_SERVICE_EVENT_SOURCE =
+      "gerrit-cluster-istio-virtual-service";
+
+  @Override
+  public Map<String, EventSource> prepareEventSources(EventSourceContext<GerritNetwork> context) {
+    InformerEventSource<DestinationRule, GerritNetwork> gerritIstioDestinationRuleEventSource =
+        new InformerEventSource<>(
+            InformerConfiguration.from(DestinationRule.class, context).build(), context);
+
+    InformerEventSource<VirtualService, GerritNetwork> virtualServiceEventSource =
+        new InformerEventSource<>(
+            InformerConfiguration.from(VirtualService.class, context).build(), context);
+
+    Map<String, EventSource> eventSources = new HashMap<>();
+    eventSources.put(ISTIO_DESTINATION_RULE_EVENT_SOURCE, gerritIstioDestinationRuleEventSource);
+    eventSources.put(ISTIO_VIRTUAL_SERVICE_EVENT_SOURCE, virtualServiceEventSource);
+    return eventSources;
+  }
+
+  @Override
+  public UpdateControl<GerritNetwork> reconcile(
+      GerritNetwork resource, Context<GerritNetwork> context) throws Exception {
+    return UpdateControl.noUpdate();
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/istio/dependent/GerritClusterIstioGateway.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/istio/dependent/GerritClusterIstioGateway.java
new file mode 100644
index 0000000..099a4ed
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/istio/dependent/GerritClusterIstioGateway.java
@@ -0,0 +1,115 @@
+// 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.network.istio.dependent;
+
+import com.google.gerrit.k8s.operator.v1alpha.api.model.cluster.GerritCluster;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.network.GerritNetwork;
+import io.fabric8.istio.api.networking.v1beta1.Gateway;
+import io.fabric8.istio.api.networking.v1beta1.GatewayBuilder;
+import io.fabric8.istio.api.networking.v1beta1.Server;
+import io.fabric8.istio.api.networking.v1beta1.ServerBuilder;
+import io.fabric8.istio.api.networking.v1beta1.ServerTLSSettingsTLSmode;
+import io.javaoperatorsdk.operator.api.reconciler.Context;
+import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+public class GerritClusterIstioGateway
+    extends CRUDKubernetesDependentResource<Gateway, GerritNetwork> {
+  public static final String NAME = "gerrit-istio-gateway";
+
+  public GerritClusterIstioGateway() {
+    super(Gateway.class);
+  }
+
+  @Override
+  public Gateway desired(GerritNetwork gerritNetwork, Context<GerritNetwork> context) {
+    return new GatewayBuilder()
+        .withNewMetadata()
+        .withName(NAME)
+        .withNamespace(gerritNetwork.getMetadata().getNamespace())
+        .withLabels(
+            GerritCluster.getLabels(
+                gerritNetwork.getMetadata().getName(), NAME, this.getClass().getSimpleName()))
+        .endMetadata()
+        .withNewSpec()
+        .withSelector(Map.of("istio", "ingressgateway"))
+        .withServers(configureServers(gerritNetwork))
+        .endSpec()
+        .build();
+  }
+
+  private List<Server> configureServers(GerritNetwork gerritNetwork) {
+    List<Server> servers = new ArrayList<>();
+    String gerritClusterHost = gerritNetwork.getSpec().getIngress().getHost();
+
+    servers.add(
+        new ServerBuilder()
+            .withNewPort()
+            .withName("http")
+            .withNumber(80)
+            .withProtocol("HTTP")
+            .endPort()
+            .withHosts(gerritClusterHost)
+            .withNewTls()
+            .withHttpsRedirect(gerritNetwork.getSpec().getIngress().getTls().isEnabled())
+            .endTls()
+            .build());
+
+    if (gerritNetwork.getSpec().getIngress().getTls().isEnabled()) {
+      servers.add(
+          new ServerBuilder()
+              .withNewPort()
+              .withName("https")
+              .withNumber(443)
+              .withProtocol("HTTPS")
+              .endPort()
+              .withHosts(gerritClusterHost)
+              .withNewTls()
+              .withMode(ServerTLSSettingsTLSmode.SIMPLE)
+              .withCredentialName(gerritNetwork.getSpec().getIngress().getTls().getSecret())
+              .endTls()
+              .build());
+    }
+
+    if (gerritNetwork.getSpec().getIngress().getSsh().isEnabled() && gerritNetwork.hasGerrits()) {
+      if (gerritNetwork.hasPrimaryGerrit()) {
+        servers.add(
+            new ServerBuilder()
+                .withNewPort()
+                .withName("ssh-primary")
+                .withNumber(gerritNetwork.getSpec().getPrimaryGerrit().getSshPort())
+                .withProtocol("TCP")
+                .endPort()
+                .withHosts(gerritClusterHost)
+                .build());
+      }
+      if (gerritNetwork.hasGerritReplica()) {
+        servers.add(
+            new ServerBuilder()
+                .withNewPort()
+                .withName("ssh-replica")
+                .withNumber(gerritNetwork.getSpec().getGerritReplica().getSshPort())
+                .withProtocol("TCP")
+                .endPort()
+                .withHosts(gerritClusterHost)
+                .build());
+      }
+    }
+
+    return servers;
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/istio/dependent/GerritIstioCondition.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/istio/dependent/GerritIstioCondition.java
new file mode 100644
index 0000000..ac6c6c3
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/istio/dependent/GerritIstioCondition.java
@@ -0,0 +1,34 @@
+// Copyright (C) 2023 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.network.istio.dependent;
+
+import com.google.gerrit.k8s.operator.v1alpha.api.model.network.GerritNetwork;
+import io.fabric8.istio.api.networking.v1beta1.VirtualService;
+import io.javaoperatorsdk.operator.api.reconciler.Context;
+import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource;
+import io.javaoperatorsdk.operator.processing.dependent.workflow.Condition;
+
+public class GerritIstioCondition implements Condition<VirtualService, GerritNetwork> {
+
+  @Override
+  public boolean isMet(
+      DependentResource<VirtualService, GerritNetwork> dependentResource,
+      GerritNetwork gerritNetwork,
+      Context<GerritNetwork> context) {
+
+    return gerritNetwork.getSpec().getIngress().isEnabled()
+        && (gerritNetwork.hasGerrits() || gerritNetwork.hasReceiver());
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/istio/dependent/GerritIstioDestinationRule.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/istio/dependent/GerritIstioDestinationRule.java
new file mode 100644
index 0000000..98f8267
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/istio/dependent/GerritIstioDestinationRule.java
@@ -0,0 +1,131 @@
+// Copyright (C) 2023 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.network.istio.dependent;
+
+import static com.google.gerrit.k8s.operator.v1alpha.api.model.network.GerritNetwork.SESSION_COOKIE_NAME;
+import static com.google.gerrit.k8s.operator.v1alpha.api.model.network.GerritNetwork.SESSION_COOKIE_TTL;
+
+import com.google.gerrit.k8s.operator.gerrit.dependent.GerritService;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.cluster.GerritCluster;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.gerrit.GerritTemplate;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.network.GerritNetwork;
+import io.fabric8.istio.api.networking.v1beta1.DestinationRule;
+import io.fabric8.istio.api.networking.v1beta1.DestinationRuleBuilder;
+import io.fabric8.istio.api.networking.v1beta1.LoadBalancerSettingsSimpleLB;
+import io.fabric8.istio.api.networking.v1beta1.TrafficPolicy;
+import io.fabric8.istio.api.networking.v1beta1.TrafficPolicyBuilder;
+import io.javaoperatorsdk.operator.api.reconciler.Context;
+import io.javaoperatorsdk.operator.api.reconciler.dependent.Deleter;
+import io.javaoperatorsdk.operator.api.reconciler.dependent.GarbageCollected;
+import io.javaoperatorsdk.operator.processing.dependent.BulkDependentResource;
+import io.javaoperatorsdk.operator.processing.dependent.Creator;
+import io.javaoperatorsdk.operator.processing.dependent.Updater;
+import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependentResource;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+
+public class GerritIstioDestinationRule
+    extends KubernetesDependentResource<DestinationRule, GerritNetwork>
+    implements Creator<DestinationRule, GerritNetwork>,
+        Updater<DestinationRule, GerritNetwork>,
+        Deleter<GerritNetwork>,
+        BulkDependentResource<DestinationRule, GerritNetwork>,
+        GarbageCollected<GerritNetwork> {
+
+  public GerritIstioDestinationRule() {
+    super(DestinationRule.class);
+  }
+
+  protected DestinationRule desired(
+      GerritNetwork gerritNetwork, String gerritName, boolean isReplica) {
+
+    return new DestinationRuleBuilder()
+        .withNewMetadata()
+        .withName(getName(gerritName))
+        .withNamespace(gerritNetwork.getMetadata().getNamespace())
+        .withLabels(
+            GerritCluster.getLabels(
+                gerritNetwork.getMetadata().getName(),
+                getName(gerritName),
+                this.getClass().getSimpleName()))
+        .endMetadata()
+        .withNewSpec()
+        .withHost(GerritService.getHostname(gerritName, gerritNetwork.getMetadata().getNamespace()))
+        .withTrafficPolicy(getTrafficPolicy(isReplica))
+        .endSpec()
+        .build();
+  }
+
+  private TrafficPolicy getTrafficPolicy(boolean isReplica) {
+    if (isReplica) {
+      return new TrafficPolicyBuilder()
+          .withNewLoadBalancer()
+          .withNewLoadBalancerSettingsSimpleLbPolicy()
+          .withSimple(LoadBalancerSettingsSimpleLB.LEAST_CONN)
+          .endLoadBalancerSettingsSimpleLbPolicy()
+          .endLoadBalancer()
+          .build();
+    }
+    return new TrafficPolicyBuilder()
+        .withNewLoadBalancer()
+        .withNewLoadBalancerSettingsConsistentHashLbPolicy()
+        .withNewConsistentHash()
+        .withNewLoadBalancerSettingsConsistentHashLBHttpCookieKey()
+        .withNewHttpCookie()
+        .withName(SESSION_COOKIE_NAME)
+        .withTtl(SESSION_COOKIE_TTL)
+        .endHttpCookie()
+        .endLoadBalancerSettingsConsistentHashLBHttpCookieKey()
+        .endConsistentHash()
+        .endLoadBalancerSettingsConsistentHashLbPolicy()
+        .endLoadBalancer()
+        .build();
+  }
+
+  public static String getName(GerritTemplate gerrit) {
+    return gerrit.getMetadata().getName();
+  }
+
+  public static String getName(String gerritName) {
+    return gerritName;
+  }
+
+  @Override
+  public Map<String, DestinationRule> desiredResources(
+      GerritNetwork gerritNetwork, Context<GerritNetwork> context) {
+    Map<String, DestinationRule> drs = new HashMap<>();
+    if (gerritNetwork.hasPrimaryGerrit()) {
+      String primaryGerritName = gerritNetwork.getSpec().getPrimaryGerrit().getName();
+      drs.put(primaryGerritName, desired(gerritNetwork, primaryGerritName, false));
+    }
+    if (gerritNetwork.hasGerritReplica()) {
+      String gerritReplicaName = gerritNetwork.getSpec().getGerritReplica().getName();
+      drs.put(gerritReplicaName, desired(gerritNetwork, gerritReplicaName, true));
+    }
+    return drs;
+  }
+
+  @Override
+  public Map<String, DestinationRule> getSecondaryResources(
+      GerritNetwork gerritNetwork, Context<GerritNetwork> context) {
+    Set<DestinationRule> drs = context.getSecondaryResources(DestinationRule.class);
+    Map<String, DestinationRule> result = new HashMap<>(drs.size());
+    for (DestinationRule dr : drs) {
+      result.put(dr.getMetadata().getName(), dr);
+    }
+    return result;
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/istio/dependent/GerritIstioVirtualService.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/istio/dependent/GerritIstioVirtualService.java
new file mode 100644
index 0000000..f0d683c
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/istio/dependent/GerritIstioVirtualService.java
@@ -0,0 +1,238 @@
+// 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.network.istio.dependent;
+
+import com.google.gerrit.k8s.operator.gerrit.dependent.GerritService;
+import com.google.gerrit.k8s.operator.receiver.dependent.ReceiverService;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.cluster.GerritCluster;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.network.GerritNetwork;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.network.NetworkMember;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.network.NetworkMemberWithSsh;
+import io.fabric8.istio.api.networking.v1beta1.HTTPMatchRequest;
+import io.fabric8.istio.api.networking.v1beta1.HTTPMatchRequestBuilder;
+import io.fabric8.istio.api.networking.v1beta1.HTTPRoute;
+import io.fabric8.istio.api.networking.v1beta1.HTTPRouteBuilder;
+import io.fabric8.istio.api.networking.v1beta1.HTTPRouteDestination;
+import io.fabric8.istio.api.networking.v1beta1.HTTPRouteDestinationBuilder;
+import io.fabric8.istio.api.networking.v1beta1.L4MatchAttributesBuilder;
+import io.fabric8.istio.api.networking.v1beta1.RouteDestination;
+import io.fabric8.istio.api.networking.v1beta1.RouteDestinationBuilder;
+import io.fabric8.istio.api.networking.v1beta1.StringMatchBuilder;
+import io.fabric8.istio.api.networking.v1beta1.TCPRoute;
+import io.fabric8.istio.api.networking.v1beta1.TCPRouteBuilder;
+import io.fabric8.istio.api.networking.v1beta1.VirtualService;
+import io.fabric8.istio.api.networking.v1beta1.VirtualServiceBuilder;
+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 GerritIstioVirtualService
+    extends CRUDKubernetesDependentResource<VirtualService, GerritNetwork> {
+  private static final String INFO_REF_URL_PATTERN = "^/(.*)/info/refs$";
+  private static final String UPLOAD_PACK_URL_PATTERN = "^/(.*)/git-upload-pack$";
+  private static final String RECEIVE_PACK_URL_PATTERN = "^/(.*)/git-receive-pack$";
+  public static final String NAME_SUFFIX = "gerrit-http-virtual-service";
+
+  public GerritIstioVirtualService() {
+    super(VirtualService.class);
+  }
+
+  @Override
+  protected VirtualService desired(GerritNetwork gerritNetwork, Context<GerritNetwork> context) {
+    String gerritClusterHost = gerritNetwork.getSpec().getIngress().getHost();
+    String namespace = gerritNetwork.getMetadata().getNamespace();
+
+    return new VirtualServiceBuilder()
+        .withNewMetadata()
+        .withName(gerritNetwork.getDependentResourceName(NAME_SUFFIX))
+        .withNamespace(namespace)
+        .withLabels(
+            GerritCluster.getLabels(
+                gerritNetwork.getMetadata().getName(),
+                gerritNetwork.getDependentResourceName(NAME_SUFFIX),
+                this.getClass().getSimpleName()))
+        .endMetadata()
+        .withNewSpec()
+        .withHosts(gerritClusterHost)
+        .withGateways(namespace + "/" + GerritClusterIstioGateway.NAME)
+        .withHttp(getHTTPRoutes(gerritNetwork))
+        .withTcp(getTCPRoutes(gerritNetwork))
+        .endSpec()
+        .build();
+  }
+
+  private List<HTTPRoute> getHTTPRoutes(GerritNetwork gerritNetwork) {
+    String namespace = gerritNetwork.getMetadata().getNamespace();
+    List<HTTPRoute> routes = new ArrayList<>();
+    if (gerritNetwork.hasReceiver()) {
+      routes.add(
+          new HTTPRouteBuilder()
+              .withName("receiver-" + gerritNetwork.getSpec().getReceiver().getName())
+              .withMatch(getReceiverMatches())
+              .withRoute(
+                  getReceiverHTTPDestination(gerritNetwork.getSpec().getReceiver(), namespace))
+              .build());
+    }
+    if (gerritNetwork.hasGerritReplica()) {
+      HTTPRouteBuilder routeBuilder =
+          new HTTPRouteBuilder()
+              .withName("gerrit-replica-" + gerritNetwork.getSpec().getGerritReplica().getName());
+      if (gerritNetwork.hasPrimaryGerrit()) {
+        routeBuilder = routeBuilder.withMatch(getGerritReplicaMatches());
+      }
+      routes.add(
+          routeBuilder
+              .withRoute(
+                  getGerritHTTPDestinations(gerritNetwork.getSpec().getGerritReplica(), namespace))
+              .build());
+    }
+    if (gerritNetwork.hasPrimaryGerrit()) {
+      routes.add(
+          new HTTPRouteBuilder()
+              .withName("gerrit-primary-" + gerritNetwork.getSpec().getPrimaryGerrit().getName())
+              .withRoute(
+                  getGerritHTTPDestinations(gerritNetwork.getSpec().getPrimaryGerrit(), namespace))
+              .build());
+    }
+
+    return routes;
+  }
+
+  private HTTPRouteDestination getGerritHTTPDestinations(
+      NetworkMemberWithSsh networkMember, String namespace) {
+    return new HTTPRouteDestinationBuilder()
+        .withNewDestination()
+        .withHost(GerritService.getHostname(networkMember.getName(), namespace))
+        .withNewPort()
+        .withNumber(networkMember.getHttpPort())
+        .endPort()
+        .endDestination()
+        .build();
+  }
+
+  private List<HTTPMatchRequest> getGerritReplicaMatches() {
+    List<HTTPMatchRequest> matches = new ArrayList<>();
+    matches.add(
+        new HTTPMatchRequestBuilder()
+            .withNewUri()
+            .withNewStringMatchRegexType()
+            .withRegex(INFO_REF_URL_PATTERN)
+            .endStringMatchRegexType()
+            .endUri()
+            .withQueryParams(
+                Map.of(
+                    "service",
+                    new StringMatchBuilder()
+                        .withNewStringMatchExactType("git-upload-pack")
+                        .build()))
+            .withIgnoreUriCase()
+            .withNewMethod()
+            .withNewStringMatchExactType()
+            .withExact("GET")
+            .endStringMatchExactType()
+            .endMethod()
+            .build());
+    matches.add(
+        new HTTPMatchRequestBuilder()
+            .withNewUri()
+            .withNewStringMatchRegexType()
+            .withRegex(UPLOAD_PACK_URL_PATTERN)
+            .endStringMatchRegexType()
+            .endUri()
+            .withIgnoreUriCase()
+            .withNewMethod()
+            .withNewStringMatchExactType()
+            .withExact("POST")
+            .endStringMatchExactType()
+            .endMethod()
+            .build());
+    return matches;
+  }
+
+  private HTTPRouteDestination getReceiverHTTPDestination(
+      NetworkMember receiver, String namespace) {
+    return new HTTPRouteDestinationBuilder()
+        .withNewDestination()
+        .withHost(ReceiverService.getHostname(receiver.getName(), namespace))
+        .withNewPort()
+        .withNumber(receiver.getHttpPort())
+        .endPort()
+        .endDestination()
+        .build();
+  }
+
+  private List<HTTPMatchRequest> getReceiverMatches() {
+    List<HTTPMatchRequest> matches = new ArrayList<>();
+    matches.add(
+        new HTTPMatchRequestBuilder()
+            .withUri(new StringMatchBuilder().withNewStringMatchPrefixType("/a/projects/").build())
+            .build());
+    matches.add(
+        new HTTPMatchRequestBuilder()
+            .withNewUri()
+            .withNewStringMatchRegexType()
+            .withRegex(RECEIVE_PACK_URL_PATTERN)
+            .endStringMatchRegexType()
+            .endUri()
+            .build());
+    matches.add(
+        new HTTPMatchRequestBuilder()
+            .withNewUri()
+            .withNewStringMatchRegexType()
+            .withRegex(INFO_REF_URL_PATTERN)
+            .endStringMatchRegexType()
+            .endUri()
+            .withQueryParams(
+                Map.of(
+                    "service",
+                    new StringMatchBuilder()
+                        .withNewStringMatchExactType("git-receive-pack")
+                        .build()))
+            .build());
+    return matches;
+  }
+
+  private List<TCPRoute> getTCPRoutes(GerritNetwork gerritNetwork) {
+    List<TCPRoute> routes = new ArrayList<>();
+    for (NetworkMemberWithSsh gerrit : gerritNetwork.getSpec().getGerrits()) {
+      if (gerritNetwork.getSpec().getIngress().getSsh().isEnabled() && gerrit.getSshPort() > 0) {
+        routes.add(
+            new TCPRouteBuilder()
+                .withMatch(
+                    List.of(new L4MatchAttributesBuilder().withPort(gerrit.getSshPort()).build()))
+                .withRoute(
+                    getGerritTCPDestination(gerrit, gerritNetwork.getMetadata().getNamespace()))
+                .build());
+      }
+    }
+    return routes;
+  }
+
+  private RouteDestination getGerritTCPDestination(
+      NetworkMemberWithSsh networkMember, String namespace) {
+    return new RouteDestinationBuilder()
+        .withNewDestination()
+        .withHost(GerritService.getHostname(networkMember.getName(), namespace))
+        .withNewPort()
+        .withNumber(networkMember.getSshPort())
+        .endPort()
+        .endDestination()
+        .build();
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/none/GerritNoIngressReconciler.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/none/GerritNoIngressReconciler.java
new file mode 100644
index 0000000..2179260
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/network/none/GerritNoIngressReconciler.java
@@ -0,0 +1,33 @@
+// Copyright (C) 2023 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.network.none;
+
+import com.google.gerrit.k8s.operator.v1alpha.api.model.network.GerritNetwork;
+import com.google.inject.Singleton;
+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;
+
+@Singleton
+@ControllerConfiguration
+public class GerritNoIngressReconciler implements Reconciler<GerritNetwork> {
+
+  @Override
+  public UpdateControl<GerritNetwork> reconcile(
+      GerritNetwork resource, Context<GerritNetwork> context) throws Exception {
+    return UpdateControl.noUpdate();
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/receiver/ReceiverReconciler.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/receiver/ReceiverReconciler.java
new file mode 100644
index 0000000..b48a05f
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/receiver/ReceiverReconciler.java
@@ -0,0 +1,149 @@
+// 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.receiver.dependent.ReceiverDeployment;
+import com.google.gerrit.k8s.operator.receiver.dependent.ReceiverService;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.receiver.Receiver;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.receiver.ReceiverStatus;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+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.HashMap;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+@Singleton
+@ControllerConfiguration(
+    dependents = {
+      @Dependent(name = "receiver-deployment", type = ReceiverDeployment.class),
+      @Dependent(
+          name = "receiver-service",
+          type = ReceiverService.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;
+
+  @Inject
+  public ReceiverReconciler(KubernetesClient client) {
+    this.client = client;
+  }
+
+  @Override
+  public Map<String, EventSource> prepareEventSources(EventSourceContext<Receiver> 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 = new HashMap<>();
+    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/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/receiver/dependent/ReceiverDeployment.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/receiver/dependent/ReceiverDeployment.java
new file mode 100644
index 0000000..16c0072
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/receiver/dependent/ReceiverDeployment.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.receiver.dependent;
+
+import com.google.gerrit.k8s.operator.receiver.ReceiverReconciler;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.cluster.GerritCluster;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.receiver.Receiver;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.shared.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 ReceiverDeployment extends CRUDKubernetesDependentResource<Deployment, Receiver> {
+  public static final int HTTP_PORT = 80;
+
+  public ReceiverDeployment() {
+    super(Deployment.class);
+  }
+
+  @Override
+  protected Deployment desired(Receiver receiver, Context<Receiver> context) {
+    DeploymentBuilder deploymentBuilder = new DeploymentBuilder();
+
+    List<Container> initContainers = new ArrayList<>();
+
+    NfsWorkaroundConfig nfsWorkaround =
+        receiver.getSpec().getStorage().getStorageClasses().getNfsWorkaround();
+    if (nfsWorkaround.isEnabled() && nfsWorkaround.isChownOnStartup()) {
+      initContainers.add(
+          GerritCluster.createNfsInitContainer(
+              receiver
+                      .getSpec()
+                      .getStorage()
+                      .getStorageClasses()
+                      .getNfsWorkaround()
+                      .getIdmapdConfig()
+                  != null,
+              receiver.getSpec().getContainerImages()));
+    }
+
+    deploymentBuilder
+        .withApiVersion("apps/v1")
+        .withNewMetadata()
+        .withName(receiver.getMetadata().getName())
+        .withNamespace(receiver.getMetadata().getNamespace())
+        .withLabels(getLabels(receiver))
+        .endMetadata()
+        .withNewSpec()
+        .withReplicas(receiver.getSpec().getReplicas())
+        .withNewStrategy()
+        .withNewRollingUpdate()
+        .withMaxSurge(receiver.getSpec().getMaxSurge())
+        .withMaxUnavailable(receiver.getSpec().getMaxUnavailable())
+        .endRollingUpdate()
+        .endStrategy()
+        .withNewSelector()
+        .withMatchLabels(getSelectorLabels(receiver))
+        .endSelector()
+        .withNewTemplate()
+        .withNewMetadata()
+        .withLabels(getLabels(receiver))
+        .endMetadata()
+        .withNewSpec()
+        .withTolerations(receiver.getSpec().getTolerations())
+        .withTopologySpreadConstraints(receiver.getSpec().getTopologySpreadConstraints())
+        .withAffinity(receiver.getSpec().getAffinity())
+        .withPriorityClassName(receiver.getSpec().getPriorityClassName())
+        .addAllToImagePullSecrets(receiver.getSpec().getContainerImages().getImagePullSecrets())
+        .withNewSecurityContext()
+        .withFsGroup(100L)
+        .endSecurityContext()
+        .addAllToInitContainers(initContainers)
+        .addNewContainer()
+        .withName("apache-git-http-backend")
+        .withImagePullPolicy(receiver.getSpec().getContainerImages().getImagePullPolicy())
+        .withImage(
+            receiver
+                .getSpec()
+                .getContainerImages()
+                .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, false))
+        .endContainer()
+        .addAllToVolumes(getVolumes(receiver))
+        .endSpec()
+        .endTemplate()
+        .endSpec();
+
+    return deploymentBuilder.build();
+  }
+
+  private static String getComponentName(Receiver receiver) {
+    return String.format("receiver-deployment-%s", receiver.getMetadata().getName());
+  }
+
+  public static Map<String, String> getSelectorLabels(Receiver receiver) {
+    return GerritCluster.getSelectorLabels(
+        receiver.getMetadata().getName(), getComponentName(receiver));
+  }
+
+  public static Map<String, String> getLabels(Receiver receiver) {
+    return GerritCluster.getLabels(
+        receiver.getMetadata().getName(),
+        getComponentName(receiver),
+        ReceiverReconciler.class.getSimpleName());
+  }
+
+  private Set<Volume> getVolumes(Receiver receiver) {
+    Set<Volume> volumes = new HashSet<>();
+    volumes.add(
+        GerritCluster.getSharedVolume(
+            receiver.getSpec().getStorage().getSharedStorage().getExternalPVC()));
+
+    volumes.add(
+        new VolumeBuilder()
+            .withName(receiver.getSpec().getCredentialSecretRef())
+            .withNewSecret()
+            .withSecretName(receiver.getSpec().getCredentialSecretRef())
+            .endSecret()
+            .build());
+
+    NfsWorkaroundConfig nfsWorkaround =
+        receiver.getSpec().getStorage().getStorageClasses().getNfsWorkaround();
+    if (nfsWorkaround.isEnabled() && nfsWorkaround.getIdmapdConfig() != null) {
+      volumes.add(GerritCluster.getNfsImapdConfigVolume());
+    }
+
+    return volumes;
+  }
+
+  private Set<VolumeMount> getVolumeMounts(Receiver receiver, 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 =
+        receiver.getSpec().getStorage().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/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/receiver/dependent/ReceiverSecret.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/receiver/dependent/ReceiverSecret.java
new file mode 100644
index 0000000..ca4700a
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/receiver/dependent/ReceiverSecret.java
@@ -0,0 +1,45 @@
+// Copyright (C) 2023 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.dependent;
+
+import com.google.gerrit.k8s.operator.v1alpha.api.model.receiver.Receiver;
+import io.fabric8.kubernetes.api.model.Secret;
+import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent;
+import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependentResource;
+import io.javaoperatorsdk.operator.processing.event.ResourceID;
+import io.javaoperatorsdk.operator.processing.event.source.SecondaryToPrimaryMapper;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+@KubernetesDependent
+public class ReceiverSecret extends KubernetesDependentResource<Secret, Receiver>
+    implements SecondaryToPrimaryMapper<Secret> {
+  public ReceiverSecret() {
+    super(Secret.class);
+  }
+
+  @Override
+  public Set<ResourceID> toPrimaryResourceIDs(Secret secret) {
+    return client
+        .resources(Receiver.class)
+        .inNamespace(secret.getMetadata().getNamespace())
+        .list()
+        .getItems()
+        .stream()
+        .filter(g -> g.getSpec().getCredentialSecretRef().equals(secret.getMetadata().getName()))
+        .map(g -> ResourceID.fromResource(g))
+        .collect(Collectors.toSet());
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/receiver/dependent/ReceiverService.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/receiver/dependent/ReceiverService.java
new file mode 100644
index 0000000..3ccb644
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/receiver/dependent/ReceiverService.java
@@ -0,0 +1,91 @@
+// 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.dependent;
+
+import static com.google.gerrit.k8s.operator.receiver.dependent.ReceiverDeployment.HTTP_PORT;
+
+import com.google.gerrit.k8s.operator.receiver.ReceiverReconciler;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.cluster.GerritCluster;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.receiver.Receiver;
+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 ReceiverService extends CRUDKubernetesDependentResource<Service, Receiver> {
+  public static final String HTTP_PORT_NAME = "http";
+
+  public ReceiverService() {
+    super(Service.class);
+  }
+
+  @Override
+  protected Service desired(Receiver receiver, Context<Receiver> context) {
+    return new ServiceBuilder()
+        .withApiVersion("v1")
+        .withNewMetadata()
+        .withName(getName(receiver))
+        .withNamespace(receiver.getMetadata().getNamespace())
+        .withLabels(getLabels(receiver))
+        .endMetadata()
+        .withNewSpec()
+        .withType(receiver.getSpec().getService().getType())
+        .withPorts(getServicePorts(receiver))
+        .withSelector(ReceiverDeployment.getSelectorLabels(receiver))
+        .endSpec()
+        .build();
+  }
+
+  public static String getName(Receiver receiver) {
+    return receiver.getMetadata().getName();
+  }
+
+  public static String getName(String receiverName) {
+    return receiverName;
+  }
+
+  public static Map<String, String> getLabels(Receiver receiver) {
+    return GerritCluster.getLabels(
+        receiver.getMetadata().getName(),
+        "receiver-service",
+        ReceiverReconciler.class.getSimpleName());
+  }
+
+  public static String getHostname(Receiver receiver) {
+    return getHostname(receiver.getMetadata().getName(), receiver.getMetadata().getNamespace());
+  }
+
+  public static String getHostname(String receiverName, String namespace) {
+    return String.format("%s.%s.svc.cluster.local", getName(receiverName), namespace);
+  }
+
+  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/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/server/AbstractKeyStoreProvider.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/server/AbstractKeyStoreProvider.java
new file mode 100644
index 0000000..3b71e89
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/server/AbstractKeyStoreProvider.java
@@ -0,0 +1,52 @@
+// Copyright (C) 2023 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.server;
+
+import java.io.IOException;
+import java.security.KeyStore;
+import java.security.KeyStoreException;
+import java.security.NoSuchAlgorithmException;
+import java.security.cert.CertificateEncodingException;
+import java.security.cert.CertificateException;
+import java.util.Base64;
+
+public abstract class AbstractKeyStoreProvider implements KeyStoreProvider {
+  private static final String ALIAS = "operator";
+  private static final String CERT_PREFIX = "-----BEGIN CERTIFICATE-----";
+  private static final String CERT_SUFFIX = "-----END CERTIFICATE-----";
+
+  final String getAlias() {
+    return ALIAS;
+  }
+
+  @Override
+  public final String getCertificate()
+      throws CertificateEncodingException, KeyStoreException, NoSuchAlgorithmException,
+          CertificateException, IOException {
+    StringBuilder cert = new StringBuilder();
+    cert.append(CERT_PREFIX);
+    cert.append("\n");
+    cert.append(
+        Base64.getEncoder().encodeToString(getKeyStore().getCertificate(getAlias()).getEncoded()));
+    cert.append("\n");
+    cert.append(CERT_SUFFIX);
+    return cert.toString();
+  }
+
+  private final KeyStore getKeyStore()
+      throws KeyStoreException, NoSuchAlgorithmException, CertificateException, IOException {
+    return KeyStore.getInstance(getKeyStorePath().toFile(), getKeyStorePassword().toCharArray());
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/server/AdmissionWebhookServlet.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/server/AdmissionWebhookServlet.java
new file mode 100644
index 0000000..a886988
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/server/AdmissionWebhookServlet.java
@@ -0,0 +1,27 @@
+// Copyright (C) 2023 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.server;
+
+import jakarta.servlet.http.HttpServlet;
+
+public abstract class AdmissionWebhookServlet extends HttpServlet {
+  private static final long serialVersionUID = 1L;
+
+  public abstract String getName();
+
+  public abstract String getVersion();
+
+  public abstract String getURI();
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/server/FileSystemKeyStoreProvider.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/server/FileSystemKeyStoreProvider.java
new file mode 100644
index 0000000..a56da7f
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/server/FileSystemKeyStoreProvider.java
@@ -0,0 +1,36 @@
+// Copyright (C) 2023 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.server;
+
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+@Singleton
+public class FileSystemKeyStoreProvider extends AbstractKeyStoreProvider {
+  static final String KEYSTORE_PATH = "/operator/keystore.jks";
+  static final String KEYSTORE_PWD_FILE = "/operator/keystore.password";
+
+  @Override
+  public Path getKeyStorePath() {
+    return Path.of(KEYSTORE_PATH);
+  }
+
+  @Override
+  public String getKeyStorePassword() throws IOException {
+    return Files.readString(Path.of(KEYSTORE_PWD_FILE));
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/server/GeneratedKeyStoreProvider.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/server/GeneratedKeyStoreProvider.java
new file mode 100644
index 0000000..d96204d
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/server/GeneratedKeyStoreProvider.java
@@ -0,0 +1,132 @@
+// Copyright (C) 2023 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.server;
+
+import static com.google.gerrit.k8s.operator.GerritOperator.SERVICE_NAME;
+
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import com.google.inject.name.Named;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.math.BigInteger;
+import java.nio.file.Path;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.KeyStore;
+import java.security.KeyStoreException;
+import java.security.NoSuchAlgorithmException;
+import java.security.Security;
+import java.security.cert.Certificate;
+import java.security.cert.CertificateException;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.Date;
+import org.apache.commons.lang3.RandomStringUtils;
+import org.bouncycastle.asn1.ASN1Encodable;
+import org.bouncycastle.asn1.DERSequence;
+import org.bouncycastle.asn1.x500.X500Name;
+import org.bouncycastle.asn1.x509.Extension;
+import org.bouncycastle.asn1.x509.GeneralName;
+import org.bouncycastle.cert.CertIOException;
+import org.bouncycastle.cert.X509v3CertificateBuilder;
+import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
+import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
+import org.bouncycastle.jce.provider.BouncyCastleProvider;
+import org.bouncycastle.operator.ContentSigner;
+import org.bouncycastle.operator.OperatorCreationException;
+import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
+
+@Singleton
+public class GeneratedKeyStoreProvider extends AbstractKeyStoreProvider {
+  private static final Path KEYSTORE_PATH = Path.of("/tmp/keystore.jks");
+
+  private final String namespace;
+  private final String password;
+
+  @Inject
+  public GeneratedKeyStoreProvider(@Named("Namespace") String namespace) {
+    this.namespace = namespace;
+    this.password = generatePassword();
+    generateKeyStore();
+  }
+
+  @Override
+  public Path getKeyStorePath() {
+    return KEYSTORE_PATH;
+  }
+
+  @Override
+  public String getKeyStorePassword() {
+    return password;
+  }
+
+  private String getCN() {
+    return String.format("%s.%s.svc", SERVICE_NAME, namespace);
+  }
+
+  private String generatePassword() {
+    return RandomStringUtils.randomAlphabetic(10);
+  }
+
+  private Certificate generateCertificate(KeyPair keyPair)
+      throws OperatorCreationException, CertificateException, CertIOException {
+    BouncyCastleProvider bcProvider = new BouncyCastleProvider();
+    Security.addProvider(bcProvider);
+
+    Instant start = Instant.now();
+    X500Name dnName = new X500Name(String.format("cn=%s", getCN()));
+    DERSequence subjectAlternativeNames =
+        new DERSequence(new ASN1Encodable[] {new GeneralName(GeneralName.dNSName, getCN())});
+
+    X509v3CertificateBuilder certBuilder =
+        new JcaX509v3CertificateBuilder(
+                dnName,
+                BigInteger.valueOf(start.toEpochMilli()),
+                Date.from(start),
+                Date.from(start.plus(365, ChronoUnit.DAYS)),
+                dnName,
+                keyPair.getPublic())
+            .addExtension(Extension.subjectAlternativeName, true, subjectAlternativeNames);
+
+    ContentSigner contentSigner =
+        new JcaContentSignerBuilder("SHA256WithRSA").build(keyPair.getPrivate());
+    return new JcaX509CertificateConverter()
+        .setProvider(bcProvider)
+        .getCertificate(certBuilder.build(contentSigner));
+  }
+
+  private void generateKeyStore() {
+    KEYSTORE_PATH.getParent().toFile().mkdirs();
+    try (FileOutputStream fos = new FileOutputStream(KEYSTORE_PATH.toFile())) {
+      KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
+      keyPairGenerator.initialize(4096);
+      KeyPair keyPair = keyPairGenerator.generateKeyPair();
+
+      Certificate[] chain = {generateCertificate(keyPair)};
+
+      KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType());
+      keyStore.load(null, null);
+      keyStore.setKeyEntry(getAlias(), keyPair.getPrivate(), password.toCharArray(), chain);
+      keyStore.store(fos, password.toCharArray());
+    } catch (IOException
+        | NoSuchAlgorithmException
+        | CertificateException
+        | KeyStoreException
+        | OperatorCreationException e) {
+      throw new IllegalStateException("Failed to create keystore.", e);
+    }
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/server/HealthcheckServlet.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/server/HealthcheckServlet.java
new file mode 100644
index 0000000..6097bde
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/server/HealthcheckServlet.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.server;
+
+import jakarta.servlet.http.HttpServlet;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import java.io.IOException;
+
+public class HealthcheckServlet extends HttpServlet {
+  private static final long serialVersionUID = 1L;
+
+  protected void doGet(HttpServletRequest request, HttpServletResponse response)
+      throws IOException {
+    response.setContentType("application/text");
+    response.setStatus(HttpServletResponse.SC_OK);
+    response.getWriter().println("ALL GOOD.");
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/server/HttpServer.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/server/HttpServer.java
new file mode 100644
index 0000000..23b8055
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/server/HttpServer.java
@@ -0,0 +1,72 @@
+// 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.server;
+
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.Set;
+import org.eclipse.jetty.server.Connector;
+import org.eclipse.jetty.server.HttpConfiguration;
+import org.eclipse.jetty.server.HttpConnectionFactory;
+import org.eclipse.jetty.server.SecureRequestCustomizer;
+import org.eclipse.jetty.server.Server;
+import org.eclipse.jetty.server.ServerConnector;
+import org.eclipse.jetty.servlet.ServletHandler;
+import org.eclipse.jetty.servlet.ServletHolder;
+import org.eclipse.jetty.util.ssl.SslContextFactory;
+
+@Singleton
+public class HttpServer {
+  public static final String KEYSTORE_PATH = "/operator/keystore.jks";
+  public static final String KEYSTORE_PWD_FILE = "/operator/keystore.password";
+  public static final int PORT = 8080;
+
+  private final Server server = new Server();
+  private final KeyStoreProvider keyStoreProvider;
+  private final Set<AdmissionWebhookServlet> admissionWebhookServlets;
+
+  @Inject
+  public HttpServer(
+      KeyStoreProvider keyStoreProvider, Set<AdmissionWebhookServlet> admissionWebhookServlets) {
+    this.keyStoreProvider = keyStoreProvider;
+    this.admissionWebhookServlets = admissionWebhookServlets;
+  }
+
+  public void start() throws Exception {
+    SslContextFactory.Server ssl = new SslContextFactory.Server();
+    ssl.setKeyStorePath(keyStoreProvider.getKeyStorePath().toString());
+    ssl.setTrustStorePath(keyStoreProvider.getKeyStorePath().toString());
+    ssl.setKeyStorePassword(keyStoreProvider.getKeyStorePassword());
+    ssl.setTrustStorePassword(keyStoreProvider.getKeyStorePassword());
+    ssl.setSniRequired(false);
+
+    HttpConfiguration sslConfiguration = new HttpConfiguration();
+    sslConfiguration.addCustomizer(new SecureRequestCustomizer(false));
+    HttpConnectionFactory httpConnectionFactory = new HttpConnectionFactory(sslConfiguration);
+
+    ServerConnector connector = new ServerConnector(server, ssl, httpConnectionFactory);
+    connector.setPort(PORT);
+    server.setConnectors(new Connector[] {connector});
+
+    ServletHandler servletHandler = new ServletHandler();
+    for (AdmissionWebhookServlet servlet : admissionWebhookServlets) {
+      servletHandler.addServletWithMapping(new ServletHolder(servlet), servlet.getURI());
+    }
+    servletHandler.addServletWithMapping(HealthcheckServlet.class, "/health");
+    server.setHandler(servletHandler);
+
+    server.start();
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/server/KeyStoreProvider.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/server/KeyStoreProvider.java
new file mode 100644
index 0000000..c41777f
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/server/KeyStoreProvider.java
@@ -0,0 +1,32 @@
+// Copyright (C) 2023 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.server;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.security.KeyStoreException;
+import java.security.NoSuchAlgorithmException;
+import java.security.cert.CertificateEncodingException;
+import java.security.cert.CertificateException;
+
+public interface KeyStoreProvider {
+  Path getKeyStorePath();
+
+  String getKeyStorePassword() throws IOException;
+
+  String getCertificate()
+      throws CertificateEncodingException, KeyStoreException, NoSuchAlgorithmException,
+          CertificateException, IOException;
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/server/ServerModule.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/server/ServerModule.java
new file mode 100644
index 0000000..ae613dd
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/server/ServerModule.java
@@ -0,0 +1,40 @@
+// Copyright (C) 2023 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.server;
+
+import static com.google.gerrit.k8s.operator.server.FileSystemKeyStoreProvider.KEYSTORE_PATH;
+
+import com.google.gerrit.k8s.operator.v1alpha.admission.servlet.GerritAdmissionWebhook;
+import com.google.gerrit.k8s.operator.v1alpha.admission.servlet.GerritClusterAdmissionWebhook;
+import com.google.gerrit.k8s.operator.v1alpha.admission.servlet.GitGcAdmissionWebhook;
+import com.google.inject.AbstractModule;
+import com.google.inject.multibindings.Multibinder;
+import java.io.File;
+
+public class ServerModule extends AbstractModule {
+  public void configure() {
+    if (new File(KEYSTORE_PATH).exists()) {
+      bind(KeyStoreProvider.class).to(FileSystemKeyStoreProvider.class);
+    } else {
+      bind(KeyStoreProvider.class).to(GeneratedKeyStoreProvider.class);
+    }
+    bind(HttpServer.class);
+    Multibinder<AdmissionWebhookServlet> admissionWebhookServlets =
+        Multibinder.newSetBinder(binder(), AdmissionWebhookServlet.class);
+    admissionWebhookServlets.addBinding().to(GerritClusterAdmissionWebhook.class);
+    admissionWebhookServlets.addBinding().to(GitGcAdmissionWebhook.class);
+    admissionWebhookServlets.addBinding().to(GerritAdmissionWebhook.class);
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/server/ValidatingAdmissionWebhookServlet.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/server/ValidatingAdmissionWebhookServlet.java
new file mode 100644
index 0000000..f6d7c39
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/server/ValidatingAdmissionWebhookServlet.java
@@ -0,0 +1,61 @@
+// Copyright (C) 2023 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.server;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.google.common.flogger.FluentLogger;
+import io.fabric8.kubernetes.api.model.HasMetadata;
+import io.fabric8.kubernetes.api.model.Status;
+import io.fabric8.kubernetes.api.model.admission.v1.AdmissionResponseBuilder;
+import io.fabric8.kubernetes.api.model.admission.v1.AdmissionReview;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import java.io.IOException;
+
+public abstract class ValidatingAdmissionWebhookServlet extends AdmissionWebhookServlet {
+  private static final long serialVersionUID = 1L;
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  public abstract Status validate(HasMetadata resource);
+
+  @Override
+  public void doPost(HttpServletRequest request, HttpServletResponse response) throws IOException {
+    ObjectMapper objectMapper = new ObjectMapper();
+    AdmissionReview admissionReq =
+        objectMapper.readValue(request.getInputStream(), AdmissionReview.class);
+
+    logger.atFine().log("Admission request received: %s", admissionReq.toString());
+
+    response.setContentType("application/json");
+    AdmissionResponseBuilder admissionRespBuilder =
+        new AdmissionResponseBuilder().withUid(admissionReq.getRequest().getUid());
+    Status validationStatus = validate((HasMetadata) admissionReq.getRequest().getObject());
+    response.setStatus(HttpServletResponse.SC_OK);
+    if (validationStatus.getCode() < 400) {
+      admissionRespBuilder = admissionRespBuilder.withAllowed(true);
+    } else {
+      admissionRespBuilder = admissionRespBuilder.withAllowed(false).withStatus(validationStatus);
+    }
+    admissionReq.setResponse(admissionRespBuilder.build());
+    objectMapper.writeValue(response.getWriter(), admissionReq);
+    logger.atFine().log(
+        "Admission request responded with %s", admissionReq.getResponse().toString());
+  }
+
+  @Override
+  public String getURI() {
+    return String.format("/admission/%s/%s", getVersion(), getName());
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/util/CRUDKubernetesDependentPVCResource.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/util/CRUDKubernetesDependentPVCResource.java
new file mode 100644
index 0000000..cd5bd8e
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/util/CRUDKubernetesDependentPVCResource.java
@@ -0,0 +1,54 @@
+// 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.util;
+
+import com.google.common.flogger.FluentLogger;
+import io.fabric8.kubernetes.api.model.HasMetadata;
+import io.fabric8.kubernetes.api.model.PersistentVolumeClaim;
+import io.fabric8.kubernetes.api.model.PersistentVolumeClaimSpec;
+import io.javaoperatorsdk.operator.api.reconciler.Context;
+import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource;
+
+public abstract class CRUDKubernetesDependentPVCResource<P extends HasMetadata>
+    extends CRUDKubernetesDependentResource<PersistentVolumeClaim, P> {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  public CRUDKubernetesDependentPVCResource() {
+    super(PersistentVolumeClaim.class);
+  }
+
+  @Override
+  protected final PersistentVolumeClaim desired(P primary, Context<P> context) {
+    PersistentVolumeClaim pvc = desiredPVC(primary, context);
+    PersistentVolumeClaim existingPvc =
+        client
+            .persistentVolumeClaims()
+            .inNamespace(pvc.getMetadata().getNamespace())
+            .withName(pvc.getMetadata().getName())
+            .get();
+    String volumeName = pvc.getSpec().getVolumeName();
+    if (existingPvc != null && (volumeName == null || volumeName.isEmpty())) {
+      logger.atFine().log(
+          "PVC %s/%s already has bound a PV. Keeping volumeName reference.",
+          pvc.getMetadata().getNamespace(), pvc.getMetadata().getName());
+      PersistentVolumeClaimSpec pvcSpec = pvc.getSpec();
+      pvcSpec.setVolumeName(existingPvc.getSpec().getVolumeName());
+      pvc.setSpec(pvcSpec);
+    }
+    return pvc;
+  }
+
+  protected abstract PersistentVolumeClaim desiredPVC(P primary, Context<P> context);
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/admission/servlet/GerritAdmissionWebhook.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/admission/servlet/GerritAdmissionWebhook.java
new file mode 100644
index 0000000..f74cb59
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/admission/servlet/GerritAdmissionWebhook.java
@@ -0,0 +1,116 @@
+// 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.v1alpha.admission.servlet;
+
+import static com.google.gerrit.k8s.operator.v1alpha.api.model.shared.GlobalRefDbConfig.RefDatabase.SPANNER;
+import static com.google.gerrit.k8s.operator.v1alpha.api.model.shared.GlobalRefDbConfig.RefDatabase.ZOOKEEPER;
+
+import com.google.gerrit.k8s.operator.gerrit.config.InvalidGerritConfigException;
+import com.google.gerrit.k8s.operator.server.ValidatingAdmissionWebhookServlet;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.gerrit.Gerrit;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.shared.GlobalRefDbConfig;
+import com.google.gerrit.k8s.operator.v1alpha.gerrit.config.GerritConfigBuilder;
+import com.google.inject.Singleton;
+import io.fabric8.kubernetes.api.model.HasMetadata;
+import io.fabric8.kubernetes.api.model.Status;
+import io.fabric8.kubernetes.api.model.StatusBuilder;
+import jakarta.servlet.http.HttpServletResponse;
+import java.util.Locale;
+
+@Singleton
+public class GerritAdmissionWebhook extends ValidatingAdmissionWebhookServlet {
+  private static final long serialVersionUID = 1L;
+
+  @Override
+  public Status validate(HasMetadata resource) {
+    if (!(resource instanceof Gerrit)) {
+      return new StatusBuilder()
+          .withCode(HttpServletResponse.SC_BAD_REQUEST)
+          .withMessage("Invalid resource. Expected Gerrit-resource for validation.")
+          .build();
+    }
+
+    Gerrit gerrit = (Gerrit) resource;
+
+    try {
+      invalidGerritConfiguration(gerrit);
+    } catch (InvalidGerritConfigException e) {
+      return new StatusBuilder()
+          .withCode(HttpServletResponse.SC_BAD_REQUEST)
+          .withMessage(e.getMessage())
+          .build();
+    }
+
+    if (noRefDbConfiguredForHA(gerrit)) {
+      return new StatusBuilder()
+          .withCode(HttpServletResponse.SC_BAD_REQUEST)
+          .withMessage(
+              "A Ref-Database is required to horizontally scale a primary Gerrit: .spec.refdb.database != NONE")
+          .build();
+    }
+
+    if (missingRefdbConfig(gerrit)) {
+      String refDbName = "";
+      switch (gerrit.getSpec().getRefdb().getDatabase()) {
+        case ZOOKEEPER:
+          refDbName = ZOOKEEPER.toString().toLowerCase(Locale.US);
+          break;
+        case SPANNER:
+          refDbName = SPANNER.toString().toLowerCase(Locale.US);
+          break;
+        default:
+          break;
+      }
+      return new StatusBuilder()
+          .withCode(HttpServletResponse.SC_BAD_REQUEST)
+          .withMessage(
+              String.format("Missing %s configuration (.spec.refdb.%s)", refDbName, refDbName))
+          .build();
+    }
+
+    return new StatusBuilder().withCode(HttpServletResponse.SC_OK).build();
+  }
+
+  private void invalidGerritConfiguration(Gerrit gerrit) throws InvalidGerritConfigException {
+    new GerritConfigBuilder(gerrit).validate();
+  }
+
+  private boolean noRefDbConfiguredForHA(Gerrit gerrit) {
+    return gerrit.getSpec().isHighlyAvailablePrimary()
+        && gerrit.getSpec().getRefdb().getDatabase().equals(GlobalRefDbConfig.RefDatabase.NONE);
+  }
+
+  private boolean missingRefdbConfig(Gerrit gerrit) {
+    GlobalRefDbConfig refDbConfig = gerrit.getSpec().getRefdb();
+    switch (refDbConfig.getDatabase()) {
+      case ZOOKEEPER:
+        return refDbConfig.getZookeeper() == null;
+      case SPANNER:
+        return refDbConfig.getSpanner() == null;
+      default:
+        return false;
+    }
+  }
+
+  @Override
+  public String getName() {
+    return "gerrit";
+  }
+
+  @Override
+  public String getVersion() {
+    return "v1alpha";
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/admission/servlet/GerritClusterAdmissionWebhook.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/admission/servlet/GerritClusterAdmissionWebhook.java
new file mode 100644
index 0000000..8817559
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/admission/servlet/GerritClusterAdmissionWebhook.java
@@ -0,0 +1,103 @@
+// Copyright (C) 2023 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.v1alpha.admission.servlet;
+
+import com.google.gerrit.k8s.operator.server.ValidatingAdmissionWebhookServlet;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.cluster.GerritCluster;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.gerrit.GerritTemplate;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.gerrit.GerritTemplateSpec.GerritMode;
+import com.google.inject.Singleton;
+import io.fabric8.kubernetes.api.model.HasMetadata;
+import io.fabric8.kubernetes.api.model.Status;
+import io.fabric8.kubernetes.api.model.StatusBuilder;
+import jakarta.servlet.http.HttpServletResponse;
+
+@Singleton
+public class GerritClusterAdmissionWebhook extends ValidatingAdmissionWebhookServlet {
+  private static final long serialVersionUID = 1L;
+
+  @Override
+  public Status validate(HasMetadata resource) {
+    if (!(resource instanceof GerritCluster)) {
+      return new StatusBuilder()
+          .withCode(HttpServletResponse.SC_BAD_REQUEST)
+          .withMessage("Invalid resource. Expected GerritCluster-resource for validation.")
+          .build();
+    }
+
+    GerritCluster gerritCluster = (GerritCluster) resource;
+
+    if (multiplePrimaryGerritInCluster(gerritCluster)) {
+      return new StatusBuilder()
+          .withCode(HttpServletResponse.SC_CONFLICT)
+          .withMessage("Only a single primary Gerrit is allowed per Gerrit Cluster.")
+          .build();
+    }
+
+    if (primaryGerritAndReceiverInCluster(gerritCluster)) {
+      return new StatusBuilder()
+          .withCode(HttpServletResponse.SC_CONFLICT)
+          .withMessage("A primary Gerrit cannot be in the same Gerrit Cluster as a Receiver.")
+          .build();
+    }
+
+    if (multipleGerritReplicaInCluster(gerritCluster)) {
+      return new StatusBuilder()
+          .withCode(HttpServletResponse.SC_CONFLICT)
+          .withMessage("Only a single Gerrit Replica is allowed per Gerrit Cluster.")
+          .build();
+    }
+
+    GerritAdmissionWebhook gerritAdmission = new GerritAdmissionWebhook();
+    for (GerritTemplate gerrit : gerritCluster.getSpec().getGerrits()) {
+      Status status = gerritAdmission.validate(gerrit.toGerrit(gerritCluster));
+      if (status.getCode() != HttpServletResponse.SC_OK) {
+        return status;
+      }
+    }
+
+    return new StatusBuilder().withCode(HttpServletResponse.SC_OK).build();
+  }
+
+  private boolean multiplePrimaryGerritInCluster(GerritCluster gerritCluster) {
+    return gerritCluster.getSpec().getGerrits().stream()
+            .filter(g -> g.getSpec().getMode() == GerritMode.PRIMARY)
+            .count()
+        > 1;
+  }
+
+  private boolean primaryGerritAndReceiverInCluster(GerritCluster gerritCluster) {
+    return gerritCluster.getSpec().getGerrits().stream()
+            .anyMatch(g -> g.getSpec().getMode() == GerritMode.PRIMARY)
+        && gerritCluster.getSpec().getReceiver() != null;
+  }
+
+  private boolean multipleGerritReplicaInCluster(GerritCluster gerritCluster) {
+    return gerritCluster.getSpec().getGerrits().stream()
+            .filter(g -> g.getSpec().getMode() == GerritMode.REPLICA)
+            .count()
+        > 1;
+  }
+
+  @Override
+  public String getName() {
+    return "gerritcluster";
+  }
+
+  @Override
+  public String getVersion() {
+    return "v1alpha";
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/admission/servlet/GitGcAdmissionWebhook.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/admission/servlet/GitGcAdmissionWebhook.java
new file mode 100644
index 0000000..3198c1a
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/admission/servlet/GitGcAdmissionWebhook.java
@@ -0,0 +1,114 @@
+// 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.v1alpha.admission.servlet;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.k8s.operator.server.ValidatingAdmissionWebhookServlet;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.gitgc.GitGarbageCollection;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import io.fabric8.kubernetes.api.model.HasMetadata;
+import io.fabric8.kubernetes.api.model.Status;
+import io.fabric8.kubernetes.api.model.StatusBuilder;
+import io.fabric8.kubernetes.client.KubernetesClient;
+import jakarta.servlet.http.HttpServletResponse;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+@Singleton
+public class GitGcAdmissionWebhook extends ValidatingAdmissionWebhookServlet {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+  private static final long serialVersionUID = 1L;
+  private static final Status OK_STATUS =
+      new StatusBuilder().withCode(HttpServletResponse.SC_OK).build();
+
+  private final KubernetesClient client;
+
+  @Inject
+  public GitGcAdmissionWebhook(KubernetesClient client) {
+    this.client = client;
+  }
+
+  @Override
+  public Status validate(HasMetadata resource) {
+    if (!(resource instanceof GitGarbageCollection)) {
+      return new StatusBuilder()
+          .withCode(HttpServletResponse.SC_BAD_REQUEST)
+          .withMessage("Invalid resource. Expected GitGarbageCollection-resource for validation.")
+          .build();
+    }
+
+    GitGarbageCollection gitGc = (GitGarbageCollection) resource;
+
+    String gitGcUid = gitGc.getMetadata().getUid();
+    List<GitGarbageCollection> gitGcs =
+        client
+            .resources(GitGarbageCollection.class)
+            .inNamespace(gitGc.getMetadata().getNamespace())
+            .list()
+            .getItems()
+            .stream()
+            .filter(gc -> !gc.getMetadata().getUid().equals(gitGcUid))
+            .collect(Collectors.toList());
+    Set<String> projects = gitGc.getSpec().getProjects();
+
+    logger.atFine().log("Detected GitGcs: %s", gitGcs);
+    if (projects.isEmpty()) {
+      if (gitGcs.stream().anyMatch(gc -> gc.getSpec().getProjects().isEmpty())) {
+        return new StatusBuilder()
+            .withCode(HttpServletResponse.SC_CONFLICT)
+            .withMessage("Only a single GitGc working on all projects allowed per GerritCluster.")
+            .build();
+      }
+      return OK_STATUS;
+    }
+
+    Set<String> projectsWithExistingGC =
+        gitGcs.stream()
+            .map(gc -> gc.getSpec().getProjects())
+            .flatMap(Collection::stream)
+            .collect(Collectors.toSet());
+    Set<String> projectsIntersection = getIntersection(projects, projectsWithExistingGC);
+    if (projectsIntersection.isEmpty()) {
+      return OK_STATUS;
+    }
+    return new StatusBuilder()
+        .withCode(HttpServletResponse.SC_CONFLICT)
+        .withMessage(
+            "Only a single GitGc is allowed to work on a given project. Conflict for projects: "
+                + projectsIntersection)
+        .build();
+  }
+
+  private Set<String> getIntersection(Set<String> set1, Set<String> set2) {
+    Set<String> intersection = new HashSet<>();
+    intersection.addAll(set1);
+    intersection.retainAll(set2);
+    return intersection;
+  }
+
+  @Override
+  public String getName() {
+    return "gitgc";
+  }
+
+  @Override
+  public String getVersion() {
+    return "v1alpha";
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/cluster/GerritCluster.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/cluster/GerritCluster.java
new file mode 100644
index 0000000..b88ab09
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/cluster/GerritCluster.java
@@ -0,0 +1,242 @@
+// 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.v1alpha.api.model.cluster;
+
+import static com.google.gerrit.k8s.operator.cluster.dependent.NfsIdmapdConfigMap.NFS_IDMAPD_CM_NAME;
+import static com.google.gerrit.k8s.operator.cluster.dependent.SharedPVC.SHARED_PVC_NAME;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.shared.ContainerImageConfig;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.shared.SharedStorage.ExternalPVCConfig;
+import io.fabric8.kubernetes.api.model.Container;
+import io.fabric8.kubernetes.api.model.ContainerBuilder;
+import io.fabric8.kubernetes.api.model.EnvVar;
+import io.fabric8.kubernetes.api.model.EnvVarBuilder;
+import io.fabric8.kubernetes.api.model.Namespaced;
+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.client.CustomResource;
+import io.fabric8.kubernetes.model.annotation.Group;
+import io.fabric8.kubernetes.model.annotation.ShortNames;
+import io.fabric8.kubernetes.model.annotation.Version;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.apache.commons.lang3.builder.ToStringBuilder;
+import org.apache.commons.lang3.builder.ToStringStyle;
+
+@Group("gerritoperator.google.com")
+@Version("v1alpha17")
+@ShortNames("gclus")
+public class GerritCluster extends CustomResource<GerritClusterSpec, GerritClusterStatus>
+    implements Namespaced {
+  private static final long serialVersionUID = 2L;
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+  private static final String SHARED_VOLUME_NAME = "shared";
+  private static final String NFS_IDMAPD_CONFIG_VOLUME_NAME = "nfs-config";
+  private static final int GERRIT_FS_UID = 1000;
+  private static final int GERRIT_FS_GID = 100;
+  public static final String PLUGIN_CACHE_MOUNT_PATH = "/var/mnt/plugin_cache";
+  public static final String PLUGIN_CACHE_SUB_DIR = "plugin_cache";
+
+  public String toString() {
+    return ToStringBuilder.reflectionToString(this, ToStringStyle.JSON_STYLE);
+  }
+
+  @JsonIgnore
+  public Map<String, String> getLabels(String component, String createdBy) {
+    return getLabels(getMetadata().getName(), component, createdBy);
+  }
+
+  // TODO(Thomas): Having so many string parameters is bad. The only parameter should be the
+  // Kubernetes resource that implements an interface that provides methods to retrieve the
+  // required information.
+  @JsonIgnore
+  public static Map<String, String> getLabels(String instance, String component, String createdBy) {
+    Map<String, String> labels = new HashMap<>();
+
+    labels.putAll(getSelectorLabels(instance, component));
+    String version = GerritCluster.class.getPackage().getImplementationVersion();
+    if (version == null || version.isBlank()) {
+      logger.atWarning().log("Unable to read Gerrit Operator version from jar.");
+      version = "unknown";
+    }
+    labels.put("app.kubernetes.io/version", version);
+    labels.put("app.kubernetes.io/created-by", createdBy);
+
+    return labels;
+  }
+
+  @JsonIgnore
+  public static Map<String, String> getSelectorLabels(String instance, String component) {
+    Map<String, String> labels = new HashMap<>();
+
+    labels.put("app.kubernetes.io/name", "gerrit");
+    labels.put("app.kubernetes.io/instance", instance);
+    labels.put("app.kubernetes.io/component", component);
+    labels.put("app.kubernetes.io/part-of", instance);
+    labels.put("app.kubernetes.io/managed-by", "gerrit-operator");
+
+    return labels;
+  }
+
+  @JsonIgnore
+  public static Volume getSharedVolume(ExternalPVCConfig externalPVC) {
+    String claimName = externalPVC.isEnabled() ? externalPVC.getClaimName() : SHARED_PVC_NAME;
+    return new VolumeBuilder()
+        .withName(SHARED_VOLUME_NAME)
+        .withNewPersistentVolumeClaim()
+        .withClaimName(claimName)
+        .endPersistentVolumeClaim()
+        .build();
+  }
+
+  @JsonIgnore
+  public static VolumeMount getGitRepositoriesVolumeMount() {
+    return getGitRepositoriesVolumeMount("/var/mnt/git");
+  }
+
+  @JsonIgnore
+  public static VolumeMount getGitRepositoriesVolumeMount(String mountPath) {
+    return new VolumeMountBuilder()
+        .withName(SHARED_VOLUME_NAME)
+        .withSubPath("git")
+        .withMountPath(mountPath)
+        .build();
+  }
+
+  @JsonIgnore
+  public static VolumeMount getHAShareVolumeMount() {
+    return getSharedVolumeMount("shared", "/var/mnt/shared");
+  }
+
+  @JsonIgnore
+  public static VolumeMount getPluginCacheVolumeMount() {
+    return getSharedVolumeMount(PLUGIN_CACHE_SUB_DIR, "/var/mnt/plugin_cache");
+  }
+
+  @JsonIgnore
+  public static VolumeMount getSharedVolumeMount(String subPath, String mountPath) {
+    return new VolumeMountBuilder()
+        .withName(SHARED_VOLUME_NAME)
+        .withSubPath(subPath)
+        .withMountPath(mountPath)
+        .build();
+  }
+
+  @JsonIgnore
+  public static VolumeMount getLogsVolumeMount() {
+    return getLogsVolumeMount("/var/mnt/logs");
+  }
+
+  @JsonIgnore
+  public static VolumeMount getLogsVolumeMount(String mountPath) {
+    return new VolumeMountBuilder()
+        .withName(SHARED_VOLUME_NAME)
+        .withSubPathExpr("logs/$(POD_NAME)")
+        .withMountPath(mountPath)
+        .build();
+  }
+
+  @JsonIgnore
+  public static Volume getNfsImapdConfigVolume() {
+    return new VolumeBuilder()
+        .withName(NFS_IDMAPD_CONFIG_VOLUME_NAME)
+        .withNewConfigMap()
+        .withName(NFS_IDMAPD_CM_NAME)
+        .endConfigMap()
+        .build();
+  }
+
+  @JsonIgnore
+  public static VolumeMount getNfsImapdConfigVolumeMount() {
+    return new VolumeMountBuilder()
+        .withName(NFS_IDMAPD_CONFIG_VOLUME_NAME)
+        .withMountPath("/etc/idmapd.conf")
+        .withSubPath("idmapd.conf")
+        .build();
+  }
+
+  @JsonIgnore
+  public Container createNfsInitContainer() {
+    return createNfsInitContainer(
+        getSpec().getStorage().getStorageClasses().getNfsWorkaround().getIdmapdConfig() != null,
+        getSpec().getContainerImages());
+  }
+
+  @JsonIgnore
+  public static Container createNfsInitContainer(
+      boolean configureIdmapd, ContainerImageConfig imageConfig) {
+    return createNfsInitContainer(configureIdmapd, imageConfig, List.of());
+  }
+
+  @JsonIgnore
+  public static Container createNfsInitContainer(
+      boolean configureIdmapd,
+      ContainerImageConfig imageConfig,
+      List<VolumeMount> additionalVolumeMounts) {
+    List<VolumeMount> volumeMounts = new ArrayList<>();
+    volumeMounts.add(getLogsVolumeMount());
+    volumeMounts.add(getGitRepositoriesVolumeMount());
+
+    volumeMounts.addAll(additionalVolumeMounts);
+
+    if (configureIdmapd) {
+      volumeMounts.add(getNfsImapdConfigVolumeMount());
+    }
+
+    StringBuilder args = new StringBuilder();
+    args.append("chown -R ");
+    args.append(GERRIT_FS_UID);
+    args.append(":");
+    args.append(GERRIT_FS_GID);
+    args.append(" ");
+    for (VolumeMount vm : volumeMounts) {
+      args.append(vm.getMountPath());
+      args.append(" ");
+    }
+
+    return new ContainerBuilder()
+        .withName("nfs-init")
+        .withImagePullPolicy(imageConfig.getImagePullPolicy())
+        .withImage(imageConfig.getBusyBox().getBusyBoxImage())
+        .withCommand(List.of("sh", "-c"))
+        .withArgs(args.toString().trim())
+        .withEnv(getPodNameEnvVar())
+        .withVolumeMounts(volumeMounts)
+        .build();
+  }
+
+  @JsonIgnore
+  public static EnvVar getPodNameEnvVar() {
+    return new EnvVarBuilder()
+        .withName("POD_NAME")
+        .withNewValueFrom()
+        .withNewFieldRef()
+        .withFieldPath("metadata.name")
+        .endFieldRef()
+        .endValueFrom()
+        .build();
+  }
+
+  @JsonIgnore
+  public String getDependentResourceName(String nameSuffix) {
+    return String.format("%s-%s", getMetadata().getName(), nameSuffix);
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/cluster/GerritClusterSpec.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/cluster/GerritClusterSpec.java
new file mode 100644
index 0000000..956c678
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/cluster/GerritClusterSpec.java
@@ -0,0 +1,91 @@
+// 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.v1alpha.api.model.cluster;
+
+import com.google.gerrit.k8s.operator.v1alpha.api.model.gerrit.GerritTemplate;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.receiver.ReceiverTemplate;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.shared.ContainerImageConfig;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.shared.GerritClusterIngressConfig;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.shared.GerritStorageConfig;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.shared.GlobalRefDbConfig;
+import java.util.ArrayList;
+import java.util.List;
+
+public class GerritClusterSpec {
+
+  private GerritStorageConfig storage = new GerritStorageConfig();
+  private ContainerImageConfig containerImages = new ContainerImageConfig();
+  private GerritClusterIngressConfig ingress = new GerritClusterIngressConfig();
+  private GlobalRefDbConfig refdb = new GlobalRefDbConfig();
+  private String serverId = "";
+  private List<GerritTemplate> gerrits = new ArrayList<>();
+  private ReceiverTemplate receiver;
+
+  public GerritStorageConfig getStorage() {
+    return storage;
+  }
+
+  public void setStorage(GerritStorageConfig storage) {
+    this.storage = storage;
+  }
+
+  public ContainerImageConfig getContainerImages() {
+    return containerImages;
+  }
+
+  public void setContainerImages(ContainerImageConfig containerImages) {
+    this.containerImages = containerImages;
+  }
+
+  public GerritClusterIngressConfig getIngress() {
+    return ingress;
+  }
+
+  public void setIngress(GerritClusterIngressConfig ingress) {
+    this.ingress = ingress;
+  }
+
+  public GlobalRefDbConfig getRefdb() {
+    return refdb;
+  }
+
+  public void setRefdb(GlobalRefDbConfig refdb) {
+    this.refdb = refdb;
+  }
+
+  public String getServerId() {
+    return serverId;
+  }
+
+  public void setServerId(String serverId) {
+    this.serverId = serverId;
+  }
+
+  public List<GerritTemplate> getGerrits() {
+    return gerrits;
+  }
+
+  public void setGerrits(List<GerritTemplate> gerrits) {
+    this.gerrits = gerrits;
+  }
+
+  public ReceiverTemplate getReceiver() {
+    return receiver;
+  }
+
+  public void setReceiver(ReceiverTemplate receiver) {
+    this.receiver = receiver;
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/cluster/GerritClusterStatus.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/cluster/GerritClusterStatus.java
new file mode 100644
index 0000000..a0b853d
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/cluster/GerritClusterStatus.java
@@ -0,0 +1,30 @@
+// 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.v1alpha.api.model.cluster;
+
+import java.util.List;
+import java.util.Map;
+
+public class GerritClusterStatus {
+  private Map<String, List<String>> members;
+
+  public Map<String, List<String>> getMembers() {
+    return members;
+  }
+
+  public void setMembers(Map<String, List<String>> members) {
+    this.members = members;
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/gerrit/Gerrit.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/gerrit/Gerrit.java
new file mode 100644
index 0000000..4027cdd
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/gerrit/Gerrit.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.v1alpha.api.model.gerrit;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+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;
+import org.apache.commons.lang3.builder.ToStringBuilder;
+import org.apache.commons.lang3.builder.ToStringStyle;
+
+@Group("gerritoperator.google.com")
+@Version("v1alpha17")
+@ShortNames("gcr")
+public class Gerrit extends CustomResource<GerritSpec, GerritStatus> implements Namespaced {
+  private static final long serialVersionUID = 2L;
+
+  public String toString() {
+    return ToStringBuilder.reflectionToString(this, ToStringStyle.JSON_STYLE);
+  }
+
+  @JsonIgnore
+  public boolean isSshEnabled() {
+    return getSpec().getService().getSshPort() > 0;
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/gerrit/GerritDebugConfig.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/gerrit/GerritDebugConfig.java
new file mode 100644
index 0000000..7e7a5fd
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/gerrit/GerritDebugConfig.java
@@ -0,0 +1,36 @@
+// Copyright (C) 2023 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.v1alpha.api.model.gerrit;
+
+public class GerritDebugConfig {
+  private boolean enabled;
+  private boolean suspend;
+
+  public boolean isEnabled() {
+    return enabled;
+  }
+
+  public void setEnabled(boolean enabled) {
+    this.enabled = enabled;
+  }
+
+  public boolean isSuspend() {
+    return suspend;
+  }
+
+  public void setSuspend(boolean suspend) {
+    this.suspend = suspend;
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/gerrit/GerritInitConfig.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/gerrit/GerritInitConfig.java
new file mode 100644
index 0000000..82dc41f
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/gerrit/GerritInitConfig.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.v1alpha.api.model.gerrit;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import java.util.List;
+
+public class GerritInitConfig {
+  private String caCertPath = "/var/config/ca.crt";
+  private boolean pluginCacheEnabled;
+  private String pluginCacheDir = "/var/mnt/plugins";
+  private List<GerritPlugin> plugins;
+  private List<GerritModule> libs;
+
+  @JsonProperty("highAvailability")
+  private boolean isHighlyAvailable;
+
+  private String refdb;
+
+  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 List<GerritPlugin> getPlugins() {
+    return plugins;
+  }
+
+  public void setPlugins(List<GerritPlugin> plugins) {
+    this.plugins = plugins;
+  }
+
+  public List<GerritModule> getLibs() {
+    return libs;
+  }
+
+  public void setLibs(List<GerritModule> libs) {
+    this.libs = libs;
+  }
+
+  @JsonProperty("highAvailability")
+  public boolean isHighlyAvailable() {
+    return isHighlyAvailable;
+  }
+
+  @JsonProperty("highAvailability")
+  public void setHighlyAvailable(boolean isHighlyAvailable) {
+    this.isHighlyAvailable = isHighlyAvailable;
+  }
+
+  public String getRefdb() {
+    return refdb;
+  }
+
+  public void setRefdb(String refdb) {
+    this.refdb = refdb;
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/gerrit/GerritModule.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/gerrit/GerritModule.java
new file mode 100644
index 0000000..5b0f241
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/gerrit/GerritModule.java
@@ -0,0 +1,66 @@
+// Copyright (C) 2023 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.v1alpha.api.model.gerrit;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import java.io.Serializable;
+
+public class GerritModule implements Serializable {
+  private static final long serialVersionUID = 1L;
+
+  private String name;
+
+  @JsonInclude(JsonInclude.Include.NON_EMPTY)
+  private String url;
+
+  @JsonInclude(JsonInclude.Include.NON_EMPTY)
+  private String sha1;
+
+  public GerritModule() {}
+
+  public GerritModule(String name) {
+    this.name = name;
+  }
+
+  public GerritModule(String name, String 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 String getUrl() {
+    return url;
+  }
+
+  public void setUrl(String url) {
+    this.url = url;
+  }
+
+  public String getSha1() {
+    return sha1;
+  }
+
+  public void setSha1(String sha1) {
+    this.sha1 = sha1;
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/gerrit/GerritPlugin.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/gerrit/GerritPlugin.java
new file mode 100644
index 0000000..196ecb4
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/gerrit/GerritPlugin.java
@@ -0,0 +1,48 @@
+// 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.v1alpha.api.model.gerrit;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonInclude;
+
+public class GerritPlugin extends GerritModule {
+  private static final long serialVersionUID = 1L;
+
+  @JsonInclude(JsonInclude.Include.NON_EMPTY)
+  private boolean installAsLibrary = false;
+
+  public GerritPlugin() {}
+
+  public GerritPlugin(String name) {
+    super(name);
+  }
+
+  public GerritPlugin(String name, String url, String sha1) {
+    super(name, url, sha1);
+  }
+
+  public boolean isInstallAsLibrary() {
+    return installAsLibrary;
+  }
+
+  public void setInstallAsLibrary(boolean installAsLibrary) {
+    this.installAsLibrary = installAsLibrary;
+  }
+
+  @JsonIgnore
+  public boolean isPackagedPlugin() {
+    return getUrl() == null;
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/gerrit/GerritProbe.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/gerrit/GerritProbe.java
new file mode 100644
index 0000000..46733ed
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/gerrit/GerritProbe.java
@@ -0,0 +1,81 @@
+// 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.v1alpha.api.model.gerrit;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.google.gerrit.k8s.operator.gerrit.dependent.GerritStatefulSet;
+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;
+import io.fabric8.kubernetes.api.model.TCPSocketAction;
+
+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(GerritStatefulSet.HTTP_PORT))
+          .build();
+
+  @JsonIgnore private ExecAction exec;
+
+  @JsonIgnore private GRPCAction grpc;
+
+  @JsonIgnore private TCPSocketAction tcpSocket;
+
+  @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 void setTcpSocket(TCPSocketAction tcpSocket) {
+    super.setTcpSocket(null);
+  }
+
+  @Override
+  public ExecAction getExec() {
+    return null;
+  }
+
+  @Override
+  public GRPCAction getGrpc() {
+    return null;
+  }
+
+  @Override
+  public HTTPGetAction getHttpGet() {
+    return HTTP_GET_ACTION;
+  }
+
+  @Override
+  public TCPSocketAction getTcpSocket() {
+    return null;
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/gerrit/GerritSite.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/gerrit/GerritSite.java
new file mode 100644
index 0000000..2b604c8
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/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.v1alpha.api.model.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/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/gerrit/GerritSpec.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/gerrit/GerritSpec.java
new file mode 100644
index 0000000..ed21087
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/gerrit/GerritSpec.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.v1alpha.api.model.gerrit;
+
+import com.google.gerrit.k8s.operator.v1alpha.api.model.shared.ContainerImageConfig;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.shared.GerritStorageConfig;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.shared.GlobalRefDbConfig;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.shared.IngressConfig;
+
+public class GerritSpec extends GerritTemplateSpec {
+  private ContainerImageConfig containerImages = new ContainerImageConfig();
+  private GerritStorageConfig storage = new GerritStorageConfig();
+  private IngressConfig ingress = new IngressConfig();
+  private GlobalRefDbConfig refdb = new GlobalRefDbConfig();
+  private String serverId = "";
+
+  public GerritSpec() {}
+
+  public GerritSpec(GerritTemplateSpec templateSpec) {
+    super(templateSpec);
+  }
+
+  public ContainerImageConfig getContainerImages() {
+    return containerImages;
+  }
+
+  public void setContainerImages(ContainerImageConfig containerImages) {
+    this.containerImages = containerImages;
+  }
+
+  public GerritStorageConfig getStorage() {
+    return storage;
+  }
+
+  public void setStorage(GerritStorageConfig storage) {
+    this.storage = storage;
+  }
+
+  public IngressConfig getIngress() {
+    return ingress;
+  }
+
+  public void setIngress(IngressConfig ingress) {
+    this.ingress = ingress;
+  }
+
+  public GlobalRefDbConfig getRefdb() {
+    return refdb;
+  }
+
+  public void setRefdb(GlobalRefDbConfig refdb) {
+    this.refdb = refdb;
+  }
+
+  public String getServerId() {
+    return serverId;
+  }
+
+  public void setServerId(String serverId) {
+    this.serverId = serverId;
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/gerrit/GerritStatus.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/gerrit/GerritStatus.java
new file mode 100644
index 0000000..f779f75
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/gerrit/GerritStatus.java
@@ -0,0 +1,48 @@
+// 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.v1alpha.api.model.gerrit;
+
+import java.util.HashMap;
+import java.util.Map;
+
+public class GerritStatus {
+  private boolean ready = false;
+  private Map<String, String> appliedConfigMapVersions = new HashMap<>();
+  private Map<String, String> appliedSecretVersions = new HashMap<>();
+
+  public boolean isReady() {
+    return ready;
+  }
+
+  public void setReady(boolean ready) {
+    this.ready = ready;
+  }
+
+  public Map<String, String> getAppliedConfigMapVersions() {
+    return appliedConfigMapVersions;
+  }
+
+  public void setAppliedConfigMapVersions(Map<String, String> appliedConfigMapVersions) {
+    this.appliedConfigMapVersions = appliedConfigMapVersions;
+  }
+
+  public Map<String, String> getAppliedSecretVersions() {
+    return appliedSecretVersions;
+  }
+
+  public void setAppliedSecretVersions(Map<String, String> appliedSecretVersions) {
+    this.appliedSecretVersions = appliedSecretVersions;
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/gerrit/GerritTemplate.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/gerrit/GerritTemplate.java
new file mode 100644
index 0000000..1144737
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/gerrit/GerritTemplate.java
@@ -0,0 +1,108 @@
+// Copyright (C) 2023 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.v1alpha.api.model.gerrit;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.annotation.JsonPropertyOrder;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.cluster.GerritCluster;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.shared.GlobalRefDbConfig;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.shared.IngressConfig;
+import io.fabric8.kubernetes.api.model.KubernetesResource;
+import io.fabric8.kubernetes.api.model.ObjectMeta;
+import io.fabric8.kubernetes.api.model.ObjectMetaBuilder;
+
+@JsonDeserialize(using = com.fasterxml.jackson.databind.JsonDeserializer.None.class)
+@JsonInclude(JsonInclude.Include.NON_NULL)
+@JsonPropertyOrder({"metadata", "spec"})
+public class GerritTemplate implements KubernetesResource {
+  private static final long serialVersionUID = 1L;
+
+  @JsonProperty("metadata")
+  private ObjectMeta metadata;
+
+  @JsonProperty("spec")
+  private GerritTemplateSpec spec;
+
+  public GerritTemplate() {}
+
+  @JsonProperty("metadata")
+  public ObjectMeta getMetadata() {
+    return metadata;
+  }
+
+  @JsonProperty("metadata")
+  public void setMetadata(ObjectMeta metadata) {
+    this.metadata = metadata;
+  }
+
+  @JsonProperty("spec")
+  public GerritTemplateSpec getSpec() {
+    return spec;
+  }
+
+  @JsonProperty("spec")
+  public void setSpec(GerritTemplateSpec spec) {
+    this.spec = spec;
+  }
+
+  @JsonIgnore
+  public Gerrit toGerrit(GerritCluster gerritCluster) {
+    Gerrit gerrit = new Gerrit();
+    gerrit.setMetadata(getGerritMetadata(gerritCluster));
+    GerritSpec gerritSpec = new GerritSpec(spec);
+    gerritSpec.setContainerImages(gerritCluster.getSpec().getContainerImages());
+    gerritSpec.setStorage(gerritCluster.getSpec().getStorage());
+    IngressConfig ingressConfig = new IngressConfig();
+    ingressConfig.setEnabled(gerritCluster.getSpec().getIngress().isEnabled());
+    ingressConfig.setHost(gerritCluster.getSpec().getIngress().getHost());
+    ingressConfig.setTlsEnabled(gerritCluster.getSpec().getIngress().getTls().isEnabled());
+    ingressConfig.setSsh(gerritCluster.getSpec().getIngress().getSsh());
+    gerritSpec.setIngress(ingressConfig);
+    gerritSpec.setServerId(getServerId(gerritCluster));
+    if (getSpec().isHighlyAvailablePrimary()) {
+      GlobalRefDbConfig refdb = gerritCluster.getSpec().getRefdb();
+      if (refdb.getZookeeper() != null && refdb.getZookeeper().getRootNode() == null) {
+        refdb
+            .getZookeeper()
+            .setRootNode(
+                gerritCluster.getMetadata().getNamespace()
+                    + "/"
+                    + gerritCluster.getMetadata().getName());
+      }
+      gerritSpec.setRefdb(gerritCluster.getSpec().getRefdb());
+    }
+    gerrit.setSpec(gerritSpec);
+    return gerrit;
+  }
+
+  @JsonIgnore
+  private ObjectMeta getGerritMetadata(GerritCluster gerritCluster) {
+    return new ObjectMetaBuilder()
+        .withName(metadata.getName())
+        .withLabels(metadata.getLabels())
+        .withNamespace(gerritCluster.getMetadata().getNamespace())
+        .build();
+  }
+
+  private String getServerId(GerritCluster gerritCluster) {
+    String serverId = gerritCluster.getSpec().getServerId();
+    return serverId.isBlank()
+        ? gerritCluster.getMetadata().getNamespace() + "/" + gerritCluster.getMetadata().getName()
+        : serverId;
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/gerrit/GerritTemplateSpec.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/gerrit/GerritTemplateSpec.java
new file mode 100644
index 0000000..4349cd1
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/gerrit/GerritTemplateSpec.java
@@ -0,0 +1,259 @@
+// Copyright (C) 2023 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.v1alpha.api.model.gerrit;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.shared.HttpSshServiceConfig;
+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;
+
+public class GerritTemplateSpec {
+  private String serviceAccount;
+
+  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 = 30L;
+
+  private HttpSshServiceConfig service = new HttpSshServiceConfig();
+
+  private GerritSite site = new GerritSite();
+  private List<GerritPlugin> plugins = List.of();
+  private List<GerritModule> libs = List.of();
+  private Map<String, String> configFiles = Map.of();
+  private String secretRef;
+  private GerritMode mode = GerritMode.PRIMARY;
+
+  private GerritDebugConfig debug = new GerritDebugConfig();
+
+  public GerritTemplateSpec() {}
+
+  public GerritTemplateSpec(GerritTemplateSpec templateSpec) {
+    this.serviceAccount = templateSpec.serviceAccount;
+    this.tolerations = templateSpec.tolerations;
+    this.affinity = templateSpec.affinity;
+    this.topologySpreadConstraints = templateSpec.topologySpreadConstraints;
+    this.priorityClassName = templateSpec.priorityClassName;
+
+    this.replicas = templateSpec.replicas;
+    this.updatePartition = templateSpec.updatePartition;
+
+    this.resources = templateSpec.resources;
+
+    this.startupProbe = templateSpec.startupProbe;
+    this.readinessProbe = templateSpec.readinessProbe;
+    this.livenessProbe = templateSpec.livenessProbe;
+
+    this.gracefulStopTimeout = templateSpec.gracefulStopTimeout;
+
+    this.service = templateSpec.service;
+
+    this.site = templateSpec.site;
+    this.plugins = templateSpec.plugins;
+    this.libs = templateSpec.libs;
+    this.configFiles = templateSpec.configFiles;
+    this.secretRef = templateSpec.secretRef;
+    this.mode = templateSpec.mode;
+
+    this.debug = templateSpec.debug;
+  }
+
+  public String getServiceAccount() {
+    return serviceAccount;
+  }
+
+  public void setServiceAccount(String serviceAccount) {
+    this.serviceAccount = serviceAccount;
+  }
+
+  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 HttpSshServiceConfig getService() {
+    return service;
+  }
+
+  public void setService(HttpSshServiceConfig 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 List<GerritModule> getLibs() {
+    return libs;
+  }
+
+  public void setLibs(List<GerritModule> libs) {
+    this.libs = libs;
+  }
+
+  public Map<String, String> getConfigFiles() {
+    return configFiles;
+  }
+
+  public void setConfigFiles(Map<String, String> configFiles) {
+    this.configFiles = configFiles;
+  }
+
+  public String getSecretRef() {
+    return secretRef;
+  }
+
+  public void setSecretRef(String secretRef) {
+    this.secretRef = secretRef;
+  }
+
+  public GerritMode getMode() {
+    return mode;
+  }
+
+  public void setMode(GerritMode mode) {
+    this.mode = mode;
+  }
+
+  public GerritDebugConfig getDebug() {
+    return debug;
+  }
+
+  public void setDebug(GerritDebugConfig debug) {
+    this.debug = debug;
+  }
+
+  public enum GerritMode {
+    PRIMARY,
+    REPLICA
+  }
+
+  @JsonIgnore
+  public boolean isHighlyAvailablePrimary() {
+    return getMode().equals(GerritMode.PRIMARY) && getReplicas() > 1;
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/gitgc/GitGarbageCollection.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/gitgc/GitGarbageCollection.java
new file mode 100644
index 0000000..7d268bd
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/gitgc/GitGarbageCollection.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.v1alpha.api.model.gitgc;
+
+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;
+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("gitgc")
+@Plural("gitgcs")
+public class GitGarbageCollection
+    extends CustomResource<GitGarbageCollectionSpec, GitGarbageCollectionStatus>
+    implements Namespaced {
+  private static final long serialVersionUID = 1L;
+
+  public String toString() {
+    return ToStringBuilder.reflectionToString(this, ToStringStyle.JSON_STYLE);
+  }
+
+  @Override
+  protected GitGarbageCollectionStatus initStatus() {
+    return new GitGarbageCollectionStatus();
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/gitgc/GitGarbageCollectionSpec.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/gitgc/GitGarbageCollectionSpec.java
new file mode 100644
index 0000000..7648f13
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/gitgc/GitGarbageCollectionSpec.java
@@ -0,0 +1,102 @@
+// 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.v1alpha.api.model.gitgc;
+
+import io.fabric8.kubernetes.api.model.Affinity;
+import io.fabric8.kubernetes.api.model.ResourceRequirements;
+import io.fabric8.kubernetes.api.model.Toleration;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+
+public class GitGarbageCollectionSpec {
+  private String cluster;
+  private String schedule;
+  private Set<String> projects;
+  private ResourceRequirements resources;
+  private List<Toleration> tolerations;
+  private Affinity affinity;
+
+  public GitGarbageCollectionSpec() {
+    resources = new ResourceRequirements();
+    projects = new HashSet<>();
+  }
+
+  public String getCluster() {
+    return cluster;
+  }
+
+  public void setCluster(String cluster) {
+    this.cluster = cluster;
+  }
+
+  public void setSchedule(String schedule) {
+    this.schedule = schedule;
+  }
+
+  public String getSchedule() {
+    return schedule;
+  }
+
+  public Set<String> getProjects() {
+    return projects;
+  }
+
+  public void setProjects(Set<String> projects) {
+    this.projects = projects;
+  }
+
+  public void setResources(ResourceRequirements resources) {
+    this.resources = resources;
+  }
+
+  public ResourceRequirements getResources() {
+    return resources;
+  }
+
+  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;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(cluster, projects, resources, schedule);
+  }
+
+  @Override
+  public boolean equals(Object obj) {
+    if (obj instanceof GitGarbageCollectionSpec) {
+      GitGarbageCollectionSpec other = (GitGarbageCollectionSpec) obj;
+      return Objects.equals(cluster, other.cluster)
+          && Objects.equals(projects, other.projects)
+          && Objects.equals(resources, other.resources)
+          && Objects.equals(schedule, other.schedule);
+    }
+    return false;
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/gitgc/GitGarbageCollectionStatus.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/gitgc/GitGarbageCollectionStatus.java
new file mode 100644
index 0000000..ddff4c7
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/gitgc/GitGarbageCollectionStatus.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.v1alpha.api.model.gitgc;
+
+import java.util.HashSet;
+import java.util.Objects;
+import java.util.Set;
+
+public class GitGarbageCollectionStatus {
+  private boolean replicateAll = false;
+  private Set<String> excludedProjects = new HashSet<>();
+  private GitGcState state = GitGcState.INACTIVE;
+
+  public boolean isReplicateAll() {
+    return replicateAll;
+  }
+
+  public void setReplicateAll(boolean replicateAll) {
+    this.replicateAll = replicateAll;
+  }
+
+  public Set<String> getExcludedProjects() {
+    return excludedProjects;
+  }
+
+  public void resetExcludedProjects() {
+    excludedProjects = new HashSet<>();
+  }
+
+  public void excludeProjects(Set<String> projects) {
+    excludedProjects.addAll(projects);
+  }
+
+  public GitGcState getState() {
+    return state;
+  }
+
+  public void setState(GitGcState state) {
+    this.state = state;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(excludedProjects, replicateAll, state);
+  }
+
+  @Override
+  public boolean equals(Object obj) {
+    if (obj instanceof GitGarbageCollectionStatus) {
+      GitGarbageCollectionStatus other = (GitGarbageCollectionStatus) obj;
+      return Objects.equals(excludedProjects, other.excludedProjects)
+          && replicateAll == other.replicateAll
+          && state == other.state;
+    }
+    return false;
+  }
+
+  public enum GitGcState {
+    ACTIVE,
+    INACTIVE,
+    CONFLICT,
+    ERROR
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/network/GerritNetwork.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/network/GerritNetwork.java
new file mode 100644
index 0000000..7596f54
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/network/GerritNetwork.java
@@ -0,0 +1,58 @@
+// Copyright (C) 2023 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.v1alpha.api.model.network;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+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;
+
+@Group("gerritoperator.google.com")
+@Version("v1alpha2")
+@ShortNames("gn")
+public class GerritNetwork extends CustomResource<GerritNetworkSpec, Status> implements Namespaced {
+  private static final long serialVersionUID = 1L;
+
+  public static final String SESSION_COOKIE_NAME = "Gerrit_Session";
+  public static final String SESSION_COOKIE_TTL = "60s";
+
+  @JsonIgnore
+  public String getDependentResourceName(String nameSuffix) {
+    return String.format("%s-%s", getMetadata().getName(), nameSuffix);
+  }
+
+  @JsonIgnore
+  public boolean hasPrimaryGerrit() {
+    return getSpec().getPrimaryGerrit() != null;
+  }
+
+  @JsonIgnore
+  public boolean hasGerritReplica() {
+    return getSpec().getGerritReplica() != null;
+  }
+
+  @JsonIgnore
+  public boolean hasGerrits() {
+    return hasGerritReplica() || hasPrimaryGerrit();
+  }
+
+  @JsonIgnore
+  public boolean hasReceiver() {
+    return getSpec().getReceiver() != null;
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/network/GerritNetworkSpec.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/network/GerritNetworkSpec.java
new file mode 100644
index 0000000..406341f
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/network/GerritNetworkSpec.java
@@ -0,0 +1,69 @@
+// Copyright (C) 2023 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.v1alpha.api.model.network;
+
+import com.google.gerrit.k8s.operator.v1alpha.api.model.shared.GerritClusterIngressConfig;
+import java.util.ArrayList;
+import java.util.List;
+
+public class GerritNetworkSpec {
+  private GerritClusterIngressConfig ingress = new GerritClusterIngressConfig();
+  private NetworkMember receiver;
+  private NetworkMemberWithSsh primaryGerrit;
+  private NetworkMemberWithSsh gerritReplica;
+
+  public GerritClusterIngressConfig getIngress() {
+    return ingress;
+  }
+
+  public void setIngress(GerritClusterIngressConfig ingress) {
+    this.ingress = ingress;
+  }
+
+  public NetworkMember getReceiver() {
+    return receiver;
+  }
+
+  public void setReceiver(NetworkMember receiver) {
+    this.receiver = receiver;
+  }
+
+  public NetworkMemberWithSsh getPrimaryGerrit() {
+    return primaryGerrit;
+  }
+
+  public void setPrimaryGerrit(NetworkMemberWithSsh primaryGerrit) {
+    this.primaryGerrit = primaryGerrit;
+  }
+
+  public NetworkMemberWithSsh getGerritReplica() {
+    return gerritReplica;
+  }
+
+  public void setGerritReplica(NetworkMemberWithSsh gerritReplica) {
+    this.gerritReplica = gerritReplica;
+  }
+
+  public List<NetworkMemberWithSsh> getGerrits() {
+    List<NetworkMemberWithSsh> gerrits = new ArrayList<>();
+    if (primaryGerrit != null) {
+      gerrits.add(primaryGerrit);
+    }
+    if (gerritReplica != null) {
+      gerrits.add(gerritReplica);
+    }
+    return gerrits;
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/network/NetworkMember.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/network/NetworkMember.java
new file mode 100644
index 0000000..433dbbe
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/network/NetworkMember.java
@@ -0,0 +1,45 @@
+// Copyright (C) 2023 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.v1alpha.api.model.network;
+
+import com.google.gerrit.k8s.operator.v1alpha.api.model.shared.HttpServiceConfig;
+
+public class NetworkMember {
+  private String name;
+  private int httpPort = 8080;
+
+  public NetworkMember() {}
+
+  public NetworkMember(String name, HttpServiceConfig serviceConfig) {
+    this.name = name;
+    this.httpPort = serviceConfig.getHttpPort();
+  }
+
+  public String getName() {
+    return name;
+  }
+
+  public void setName(String name) {
+    this.name = name;
+  }
+
+  public int getHttpPort() {
+    return httpPort;
+  }
+
+  public void setHttpPort(int httpPort) {
+    this.httpPort = httpPort;
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/network/NetworkMemberWithSsh.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/network/NetworkMemberWithSsh.java
new file mode 100644
index 0000000..1668739
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/network/NetworkMemberWithSsh.java
@@ -0,0 +1,36 @@
+// Copyright (C) 2023 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.v1alpha.api.model.network;
+
+import com.google.gerrit.k8s.operator.v1alpha.api.model.shared.HttpSshServiceConfig;
+
+public class NetworkMemberWithSsh extends NetworkMember {
+  private int sshPort = 29418;
+
+  public NetworkMemberWithSsh() {}
+
+  public NetworkMemberWithSsh(String name, HttpSshServiceConfig serviceConfig) {
+    super(name, serviceConfig);
+    this.sshPort = serviceConfig.getSshPort();
+  }
+
+  public int getSshPort() {
+    return sshPort;
+  }
+
+  public void setSshPort(int sshPort) {
+    this.sshPort = sshPort;
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/receiver/Receiver.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/receiver/Receiver.java
new file mode 100644
index 0000000..bc546df
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/receiver/Receiver.java
@@ -0,0 +1,34 @@
+// 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.v1alpha.api.model.receiver;
+
+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;
+import org.apache.commons.lang3.builder.ToStringBuilder;
+import org.apache.commons.lang3.builder.ToStringStyle;
+
+@Group("gerritoperator.google.com")
+@Version("v1alpha6")
+@ShortNames("grec")
+public class Receiver extends CustomResource<ReceiverSpec, ReceiverStatus> implements Namespaced {
+  private static final long serialVersionUID = 1L;
+
+  public String toString() {
+    return ToStringBuilder.reflectionToString(this, ToStringStyle.JSON_STYLE);
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/receiver/ReceiverProbe.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/receiver/ReceiverProbe.java
new file mode 100644
index 0000000..78aaa58
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/receiver/ReceiverProbe.java
@@ -0,0 +1,78 @@
+// 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.v1alpha.api.model.receiver;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.google.gerrit.k8s.operator.receiver.dependent.ReceiverDeployment;
+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.IntOrString;
+import io.fabric8.kubernetes.api.model.Probe;
+import io.fabric8.kubernetes.api.model.TCPSocketAction;
+import io.fabric8.kubernetes.api.model.TCPSocketActionBuilder;
+
+public class ReceiverProbe extends Probe {
+  private static final long serialVersionUID = 1L;
+
+  private static final TCPSocketAction TCP_SOCKET_ACTION =
+      new TCPSocketActionBuilder().withPort(new IntOrString(ReceiverDeployment.HTTP_PORT)).build();
+
+  @JsonIgnore private ExecAction exec;
+
+  @JsonIgnore private GRPCAction grpc;
+
+  @JsonIgnore private TCPSocketAction tcpSocket;
+
+  @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(null);
+  }
+
+  @Override
+  public void setTcpSocket(TCPSocketAction tcpSocket) {
+    super.setTcpSocket(TCP_SOCKET_ACTION);
+  }
+
+  @Override
+  public ExecAction getExec() {
+    return null;
+  }
+
+  @Override
+  public GRPCAction getGrpc() {
+    return null;
+  }
+
+  @Override
+  public HTTPGetAction getHttpGet() {
+    return null;
+  }
+
+  @Override
+  public TCPSocketAction getTcpSocket() {
+    return TCP_SOCKET_ACTION;
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/receiver/ReceiverSpec.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/receiver/ReceiverSpec.java
new file mode 100644
index 0000000..005fa49
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/receiver/ReceiverSpec.java
@@ -0,0 +1,55 @@
+// 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.v1alpha.api.model.receiver;
+
+import com.google.gerrit.k8s.operator.v1alpha.api.model.shared.ContainerImageConfig;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.shared.IngressConfig;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.shared.StorageConfig;
+
+public class ReceiverSpec extends ReceiverTemplateSpec {
+  private ContainerImageConfig containerImages = new ContainerImageConfig();
+  private StorageConfig storage = new StorageConfig();
+  private IngressConfig ingress = new IngressConfig();
+
+  public ReceiverSpec() {}
+
+  public ReceiverSpec(ReceiverTemplateSpec templateSpec) {
+    super(templateSpec);
+  }
+
+  public ContainerImageConfig getContainerImages() {
+    return containerImages;
+  }
+
+  public void setContainerImages(ContainerImageConfig containerImages) {
+    this.containerImages = containerImages;
+  }
+
+  public StorageConfig getStorage() {
+    return storage;
+  }
+
+  public void setStorage(StorageConfig storage) {
+    this.storage = storage;
+  }
+
+  public IngressConfig getIngress() {
+    return ingress;
+  }
+
+  public void setIngress(IngressConfig ingress) {
+    this.ingress = ingress;
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/receiver/ReceiverStatus.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/receiver/ReceiverStatus.java
new file mode 100644
index 0000000..915a62f
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/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.v1alpha.api.model.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/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/receiver/ReceiverTemplate.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/receiver/ReceiverTemplate.java
new file mode 100644
index 0000000..f559419
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/receiver/ReceiverTemplate.java
@@ -0,0 +1,86 @@
+// Copyright (C) 2023 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.v1alpha.api.model.receiver;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.annotation.JsonPropertyOrder;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.cluster.GerritCluster;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.shared.IngressConfig;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.shared.StorageConfig;
+import io.fabric8.kubernetes.api.model.KubernetesResource;
+import io.fabric8.kubernetes.api.model.ObjectMeta;
+import io.fabric8.kubernetes.api.model.ObjectMetaBuilder;
+
+@JsonDeserialize(using = com.fasterxml.jackson.databind.JsonDeserializer.None.class)
+@JsonInclude(JsonInclude.Include.NON_NULL)
+@JsonPropertyOrder({"metadata", "spec"})
+public class ReceiverTemplate implements KubernetesResource {
+  private static final long serialVersionUID = 1L;
+
+  @JsonProperty("metadata")
+  private ObjectMeta metadata;
+
+  @JsonProperty("spec")
+  private ReceiverTemplateSpec spec;
+
+  public ReceiverTemplate() {}
+
+  @JsonProperty("metadata")
+  public ObjectMeta getMetadata() {
+    return metadata;
+  }
+
+  @JsonProperty("metadata")
+  public void setMetadata(ObjectMeta metadata) {
+    this.metadata = metadata;
+  }
+
+  @JsonProperty("spec")
+  public ReceiverTemplateSpec getSpec() {
+    return spec;
+  }
+
+  @JsonProperty("spec")
+  public void setSpec(ReceiverTemplateSpec spec) {
+    this.spec = spec;
+  }
+
+  @JsonIgnore
+  public Receiver toReceiver(GerritCluster gerritCluster) {
+    Receiver receiver = new Receiver();
+    receiver.setMetadata(getReceiverMetadata(gerritCluster));
+    ReceiverSpec receiverSpec = new ReceiverSpec(spec);
+    receiverSpec.setContainerImages(gerritCluster.getSpec().getContainerImages());
+    receiverSpec.setStorage(new StorageConfig(gerritCluster.getSpec().getStorage()));
+    IngressConfig ingressConfig = new IngressConfig();
+    ingressConfig.setHost(gerritCluster.getSpec().getIngress().getHost());
+    ingressConfig.setTlsEnabled(gerritCluster.getSpec().getIngress().getTls().isEnabled());
+    receiverSpec.setIngress(ingressConfig);
+    receiver.setSpec(receiverSpec);
+    return receiver;
+  }
+
+  @JsonIgnore
+  private ObjectMeta getReceiverMetadata(GerritCluster gerritCluster) {
+    return new ObjectMetaBuilder()
+        .withName(metadata.getName())
+        .withLabels(metadata.getLabels())
+        .withNamespace(gerritCluster.getMetadata().getNamespace())
+        .build();
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/receiver/ReceiverTemplateSpec.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/receiver/ReceiverTemplateSpec.java
new file mode 100644
index 0000000..22ee904
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/receiver/ReceiverTemplateSpec.java
@@ -0,0 +1,163 @@
+// 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.v1alpha.api.model.receiver;
+
+import com.google.gerrit.k8s.operator.v1alpha.api.model.shared.HttpServiceConfig;
+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 ReceiverTemplateSpec {
+  private List<Toleration> tolerations = new ArrayList<>();
+  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 HttpServiceConfig service = new HttpServiceConfig();
+
+  private String credentialSecretRef;
+
+  public ReceiverTemplateSpec() {}
+
+  public ReceiverTemplateSpec(ReceiverTemplateSpec templateSpec) {
+    this.tolerations = templateSpec.tolerations;
+    this.affinity = templateSpec.affinity;
+    this.topologySpreadConstraints = templateSpec.topologySpreadConstraints;
+    this.priorityClassName = templateSpec.priorityClassName;
+
+    this.replicas = templateSpec.replicas;
+
+    this.resources = templateSpec.resources;
+    this.maxSurge = templateSpec.maxSurge;
+    this.maxUnavailable = templateSpec.maxUnavailable;
+
+    this.readinessProbe = templateSpec.readinessProbe;
+    this.livenessProbe = templateSpec.livenessProbe;
+
+    this.service = templateSpec.service;
+
+    this.credentialSecretRef = templateSpec.credentialSecretRef;
+  }
+
+  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 HttpServiceConfig getService() {
+    return service;
+  }
+
+  public void setService(HttpServiceConfig service) {
+    this.service = service;
+  }
+
+  public String getCredentialSecretRef() {
+    return credentialSecretRef;
+  }
+
+  public void setCredentialSecretRef(String credentialSecretRef) {
+    this.credentialSecretRef = credentialSecretRef;
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/shared/BusyBoxImage.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/shared/BusyBoxImage.java
new file mode 100644
index 0000000..5c5388f
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/shared/BusyBoxImage.java
@@ -0,0 +1,62 @@
+// 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.v1alpha.api.model.shared;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+
+public class BusyBoxImage {
+  private String registry;
+  private String tag;
+
+  public BusyBoxImage() {
+    this.registry = "docker.io";
+    this.tag = "latest";
+  }
+
+  public void setRegistry(String registry) {
+    this.registry = registry;
+  }
+
+  public String getRegistry() {
+    return registry;
+  }
+
+  public void setTag(String tag) {
+    this.tag = tag;
+  }
+
+  public String getTag() {
+    return tag;
+  }
+
+  @JsonIgnore
+  public String getBusyBoxImage() {
+    StringBuilder builder = new StringBuilder();
+
+    if (registry != null) {
+      builder.append(registry);
+      builder.append("/");
+    }
+
+    builder.append("busybox");
+
+    if (tag != null) {
+      builder.append(":");
+      builder.append(tag);
+    }
+
+    return builder.toString();
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/shared/ContainerImageConfig.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/shared/ContainerImageConfig.java
new file mode 100644
index 0000000..4f84824
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/shared/ContainerImageConfig.java
@@ -0,0 +1,58 @@
+// Copyright (C) 2023 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.v1alpha.api.model.shared;
+
+import io.fabric8.kubernetes.api.model.LocalObjectReference;
+import java.util.HashSet;
+import java.util.Set;
+
+public class ContainerImageConfig {
+  private String imagePullPolicy = "Always";
+  private Set<LocalObjectReference> imagePullSecrets = new HashSet<>();
+  private BusyBoxImage busyBox = new BusyBoxImage();
+  private GerritRepositoryConfig gerritImages = new GerritRepositoryConfig();
+
+  public String getImagePullPolicy() {
+    return imagePullPolicy;
+  }
+
+  public void setImagePullPolicy(String imagePullPolicy) {
+    this.imagePullPolicy = imagePullPolicy;
+  }
+
+  public Set<LocalObjectReference> getImagePullSecrets() {
+    return imagePullSecrets;
+  }
+
+  public void setImagePullSecrets(Set<LocalObjectReference> imagePullSecrets) {
+    this.imagePullSecrets = imagePullSecrets;
+  }
+
+  public BusyBoxImage getBusyBox() {
+    return busyBox;
+  }
+
+  public void setBusyBox(BusyBoxImage busyBox) {
+    this.busyBox = busyBox;
+  }
+
+  public GerritRepositoryConfig getGerritImages() {
+    return gerritImages;
+  }
+
+  public void setGerritImages(GerritRepositoryConfig gerritImages) {
+    this.gerritImages = gerritImages;
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/shared/GerritClusterIngressConfig.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/shared/GerritClusterIngressConfig.java
new file mode 100644
index 0000000..a915820
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/shared/GerritClusterIngressConfig.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.v1alpha.api.model.shared;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import java.util.Map;
+
+public class GerritClusterIngressConfig {
+  private boolean enabled = false;
+  private String host;
+  private Map<String, String> annotations;
+  private GerritIngressTlsConfig tls = new GerritIngressTlsConfig();
+  private GerritIngressSshConfig ssh = new GerritIngressSshConfig();
+  private GerritIngressAmbassadorConfig ambassador = new GerritIngressAmbassadorConfig();
+
+  public boolean isEnabled() {
+    return enabled;
+  }
+
+  public void setEnabled(boolean enabled) {
+    this.enabled = enabled;
+  }
+
+  public String getHost() {
+    return host;
+  }
+
+  public void setHost(String host) {
+    this.host = host;
+  }
+
+  public Map<String, String> getAnnotations() {
+    return annotations;
+  }
+
+  public void setAnnotations(Map<String, String> annotations) {
+    this.annotations = annotations;
+  }
+
+  public GerritIngressTlsConfig getTls() {
+    return tls;
+  }
+
+  public void setTls(GerritIngressTlsConfig tls) {
+    this.tls = tls;
+  }
+
+  public GerritIngressSshConfig getSsh() {
+    return ssh;
+  }
+
+  public void setSsh(GerritIngressSshConfig ssh) {
+    this.ssh = ssh;
+  }
+
+  public GerritIngressAmbassadorConfig getAmbassador() {
+    return ambassador;
+  }
+
+  public void setAmbassador(GerritIngressAmbassadorConfig ambassador) {
+    this.ambassador = ambassador;
+  }
+
+  @JsonIgnore
+  public String getFullHostnameForService(String svcName) {
+    return getFullHostnameForService(svcName, getHost());
+  }
+
+  @JsonIgnore
+  public static String getFullHostnameForService(String svcName, String ingressHost) {
+    return String.format("%s.%s", svcName, ingressHost);
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/shared/GerritIngressAmbassadorConfig.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/shared/GerritIngressAmbassadorConfig.java
new file mode 100644
index 0000000..b34eb67
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/shared/GerritIngressAmbassadorConfig.java
@@ -0,0 +1,38 @@
+// Copyright (C) 2023 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.v1alpha.api.model.shared;
+
+import java.util.List;
+
+public class GerritIngressAmbassadorConfig {
+  private List<String> id;
+  private boolean createHost;
+
+  public List<String> getId() {
+    return this.id;
+  }
+
+  public void setId(List<String> id) {
+    this.id = id;
+  }
+
+  public boolean getCreateHost() {
+    return this.createHost;
+  }
+
+  public void setCreateHost(boolean createHost) {
+    this.createHost = createHost;
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/shared/GerritIngressSshConfig.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/shared/GerritIngressSshConfig.java
new file mode 100644
index 0000000..6203803
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/shared/GerritIngressSshConfig.java
@@ -0,0 +1,27 @@
+// Copyright (C) 2023 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.v1alpha.api.model.shared;
+
+public class GerritIngressSshConfig {
+  private boolean enabled = false;
+
+  public boolean isEnabled() {
+    return enabled;
+  }
+
+  public void setEnabled(boolean enabled) {
+    this.enabled = enabled;
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/shared/GerritIngressTlsConfig.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/shared/GerritIngressTlsConfig.java
new file mode 100644
index 0000000..b4552f5
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/shared/GerritIngressTlsConfig.java
@@ -0,0 +1,37 @@
+// 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.v1alpha.api.model.shared;
+
+public class GerritIngressTlsConfig {
+
+  private boolean enabled = false;
+  private String secret;
+
+  public boolean isEnabled() {
+    return enabled;
+  }
+
+  public void setEnabled(boolean enabled) {
+    this.enabled = enabled;
+  }
+
+  public String getSecret() {
+    return secret;
+  }
+
+  public void setSecret(String secret) {
+    this.secret = secret;
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/shared/GerritRepositoryConfig.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/shared/GerritRepositoryConfig.java
new file mode 100644
index 0000000..f593c1e
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/shared/GerritRepositoryConfig.java
@@ -0,0 +1,77 @@
+// 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.v1alpha.api.model.shared;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+
+public class GerritRepositoryConfig {
+  private String registry;
+  private String org;
+  private String tag;
+
+  public GerritRepositoryConfig() {
+    this.registry = "docker.io";
+    this.org = "k8sgerrit";
+    this.tag = "latest";
+  }
+
+  public void setRegistry(String registry) {
+    this.registry = registry;
+  }
+
+  public String getRegistry() {
+    return registry;
+  }
+
+  public String getOrg() {
+    return org;
+  }
+
+  public void setOrg(String org) {
+    this.org = org;
+  }
+
+  public void setTag(String tag) {
+    this.tag = tag;
+  }
+
+  public String getTag() {
+    return tag;
+  }
+
+  @JsonIgnore
+  public String getFullImageName(String image) {
+    StringBuilder builder = new StringBuilder();
+
+    if (registry != null) {
+      builder.append(registry);
+      builder.append("/");
+    }
+
+    if (org != null) {
+      builder.append(org);
+      builder.append("/");
+    }
+
+    builder.append(image);
+
+    if (tag != null) {
+      builder.append(":");
+      builder.append(tag);
+    }
+
+    return builder.toString();
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/shared/GerritStorageConfig.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/shared/GerritStorageConfig.java
new file mode 100644
index 0000000..d66a0cd
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/shared/GerritStorageConfig.java
@@ -0,0 +1,39 @@
+// Copyright (C) 2023 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.v1alpha.api.model.shared;
+
+public class GerritStorageConfig extends StorageConfig {
+  private PluginCacheConfig pluginCache = new PluginCacheConfig();
+
+  public PluginCacheConfig getPluginCache() {
+    return pluginCache;
+  }
+
+  public void setPluginCache(PluginCacheConfig pluginCache) {
+    this.pluginCache = pluginCache;
+  }
+
+  public class PluginCacheConfig {
+    private boolean enabled;
+
+    public boolean isEnabled() {
+      return enabled;
+    }
+
+    public void setEnabled(boolean enabled) {
+      this.enabled = enabled;
+    }
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/shared/GlobalRefDbConfig.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/shared/GlobalRefDbConfig.java
new file mode 100644
index 0000000..d338555
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/shared/GlobalRefDbConfig.java
@@ -0,0 +1,51 @@
+// Copyright (C) 2023 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.v1alpha.api.model.shared;
+
+public class GlobalRefDbConfig {
+  private RefDatabase database = RefDatabase.NONE;
+  private ZookeeperRefDbConfig zookeeper;
+  private SpannerRefDbConfig spanner;
+
+  public RefDatabase getDatabase() {
+    return database;
+  }
+
+  public void setDatabase(RefDatabase database) {
+    this.database = database;
+  }
+
+  public ZookeeperRefDbConfig getZookeeper() {
+    return zookeeper;
+  }
+
+  public void setZookeeper(ZookeeperRefDbConfig zookeeper) {
+    this.zookeeper = zookeeper;
+  }
+
+  public SpannerRefDbConfig getSpanner() {
+    return spanner;
+  }
+
+  public void setSpanner(SpannerRefDbConfig spanner) {
+    this.spanner = spanner;
+  }
+
+  public enum RefDatabase {
+    NONE,
+    ZOOKEEPER,
+    SPANNER,
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/shared/HttpServiceConfig.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/shared/HttpServiceConfig.java
new file mode 100644
index 0000000..8e7d651
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/shared/HttpServiceConfig.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.v1alpha.api.model.shared;
+
+import java.io.Serializable;
+
+public class HttpServiceConfig 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/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/shared/HttpSshServiceConfig.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/shared/HttpSshServiceConfig.java
new file mode 100644
index 0000000..8655c32
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/shared/HttpSshServiceConfig.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.v1alpha.api.model.shared;
+
+import java.io.Serializable;
+
+public class HttpSshServiceConfig extends HttpServiceConfig implements Serializable {
+  private static final long serialVersionUID = 1L;
+
+  int sshPort = 0;
+
+  public int getSshPort() {
+    return sshPort;
+  }
+
+  public void setSshPort(int sshPort) {
+    this.sshPort = sshPort;
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/shared/IngressConfig.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/shared/IngressConfig.java
new file mode 100644
index 0000000..f83b189
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/shared/IngressConfig.java
@@ -0,0 +1,75 @@
+// Copyright (C) 2023 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.v1alpha.api.model.shared;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+
+public class IngressConfig {
+  private boolean enabled;
+  private String host;
+  private boolean tlsEnabled;
+  private GerritIngressSshConfig ssh = new GerritIngressSshConfig();
+
+  public boolean isEnabled() {
+    return enabled;
+  }
+
+  public void setEnabled(boolean enabled) {
+    this.enabled = enabled;
+  }
+
+  public String getHost() {
+    return host;
+  }
+
+  public void setHost(String host) {
+    this.host = host;
+  }
+
+  public boolean isTlsEnabled() {
+    return tlsEnabled;
+  }
+
+  public void setTlsEnabled(boolean tlsEnabled) {
+    this.tlsEnabled = tlsEnabled;
+  }
+
+  public GerritIngressSshConfig getSsh() {
+    return ssh;
+  }
+
+  public void setSsh(GerritIngressSshConfig ssh) {
+    this.ssh = ssh;
+  }
+
+  @JsonIgnore
+  public String getFullHostnameForService(String svcName) {
+    return String.format("%s.%s", svcName, getHost());
+  }
+
+  @JsonIgnore
+  public String getUrl() {
+    String protocol = isTlsEnabled() ? "https" : "http";
+    String hostname = getHost();
+    return String.format("%s://%s", protocol, hostname);
+  }
+
+  @JsonIgnore
+  public String getSshUrl() {
+    String protocol = isTlsEnabled() ? "https" : "http";
+    String hostname = getHost();
+    return String.format("%s://%s", protocol, hostname);
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/shared/NfsWorkaroundConfig.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/shared/NfsWorkaroundConfig.java
new file mode 100644
index 0000000..ed4a4d1
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/shared/NfsWorkaroundConfig.java
@@ -0,0 +1,46 @@
+// 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.v1alpha.api.model.shared;
+
+public class NfsWorkaroundConfig {
+
+  private boolean enabled = false;
+  private boolean chownOnStartup = false;
+  private String idmapdConfig;
+
+  public boolean isEnabled() {
+    return enabled;
+  }
+
+  public void setEnabled(boolean enabled) {
+    this.enabled = enabled;
+  }
+
+  public boolean isChownOnStartup() {
+    return chownOnStartup;
+  }
+
+  public void setChownOnStartup(boolean chownOnStartup) {
+    this.chownOnStartup = chownOnStartup;
+  }
+
+  public String getIdmapdConfig() {
+    return idmapdConfig;
+  }
+
+  public void setIdmapdConfig(String idmapdConfig) {
+    this.idmapdConfig = idmapdConfig;
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/shared/SharedStorage.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/shared/SharedStorage.java
new file mode 100644
index 0000000..d2193c1
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/shared/SharedStorage.java
@@ -0,0 +1,78 @@
+// 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.v1alpha.api.model.shared;
+
+import io.fabric8.kubernetes.api.model.LabelSelector;
+import io.fabric8.kubernetes.api.model.Quantity;
+
+public class SharedStorage {
+  private ExternalPVCConfig externalPVC = new ExternalPVCConfig();
+  private Quantity size;
+  private String volumeName;
+  private LabelSelector selector;
+
+  public ExternalPVCConfig getExternalPVC() {
+    return externalPVC;
+  }
+
+  public void setExternalPVC(ExternalPVCConfig externalPVC) {
+    this.externalPVC = externalPVC;
+  }
+
+  public Quantity getSize() {
+    return size;
+  }
+
+  public String getVolumeName() {
+    return volumeName;
+  }
+
+  public void setSize(Quantity size) {
+    this.size = size;
+  }
+
+  public void setVolumeName(String volumeName) {
+    this.volumeName = volumeName;
+  }
+
+  public LabelSelector getSelector() {
+    return selector;
+  }
+
+  public void setSelector(LabelSelector selector) {
+    this.selector = selector;
+  }
+
+  public class ExternalPVCConfig {
+    private boolean enabled;
+    private String claimName = "";
+
+    public boolean isEnabled() {
+      return enabled;
+    }
+
+    public void setEnabled(boolean enabled) {
+      this.enabled = enabled;
+    }
+
+    public String getClaimName() {
+      return claimName;
+    }
+
+    public void setClaimName(String claimName) {
+      this.claimName = claimName;
+    }
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/shared/SpannerRefDbConfig.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/shared/SpannerRefDbConfig.java
new file mode 100644
index 0000000..eee7eab
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/shared/SpannerRefDbConfig.java
@@ -0,0 +1,45 @@
+// Copyright (C) 2023 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.v1alpha.api.model.shared;
+
+public class SpannerRefDbConfig {
+  private String projectName;
+  private String instance;
+  private String database;
+
+  public String getProjectName() {
+    return projectName;
+  }
+
+  public void setProjectName(String projectName) {
+    this.projectName = projectName;
+  }
+
+  public String getInstance() {
+    return instance;
+  }
+
+  public void setInstance(String instance) {
+    this.instance = instance;
+  }
+
+  public String getDatabase() {
+    return database;
+  }
+
+  public void setDatabase(String database) {
+    this.database = database;
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/shared/StorageClassConfig.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/shared/StorageClassConfig.java
new file mode 100644
index 0000000..de4906b
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/shared/StorageClassConfig.java
@@ -0,0 +1,46 @@
+// 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.v1alpha.api.model.shared;
+
+public class StorageClassConfig {
+
+  String readWriteOnce = "default";
+  String readWriteMany = "shared-storage";
+  NfsWorkaroundConfig nfsWorkaround = new NfsWorkaroundConfig();
+
+  public String getReadWriteOnce() {
+    return readWriteOnce;
+  }
+
+  public String getReadWriteMany() {
+    return readWriteMany;
+  }
+
+  public void setReadWriteOnce(String readWriteOnce) {
+    this.readWriteOnce = readWriteOnce;
+  }
+
+  public void setReadWriteMany(String readWriteMany) {
+    this.readWriteMany = readWriteMany;
+  }
+
+  public NfsWorkaroundConfig getNfsWorkaround() {
+    return nfsWorkaround;
+  }
+
+  public void setNfsWorkaround(NfsWorkaroundConfig nfsWorkaround) {
+    this.nfsWorkaround = nfsWorkaround;
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/shared/StorageConfig.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/shared/StorageConfig.java
new file mode 100644
index 0000000..566ce6e
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/shared/StorageConfig.java
@@ -0,0 +1,44 @@
+// Copyright (C) 2023 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.v1alpha.api.model.shared;
+
+public class StorageConfig {
+
+  private StorageClassConfig storageClasses;
+  private SharedStorage sharedStorage;
+
+  public StorageConfig() {}
+
+  public StorageConfig(GerritStorageConfig gerritStorageConfig) {
+    storageClasses = gerritStorageConfig.getStorageClasses();
+    sharedStorage = gerritStorageConfig.getSharedStorage();
+  }
+
+  public StorageClassConfig getStorageClasses() {
+    return storageClasses;
+  }
+
+  public void setStorageClasses(StorageClassConfig storageClasses) {
+    this.storageClasses = storageClasses;
+  }
+
+  public SharedStorage getSharedStorage() {
+    return sharedStorage;
+  }
+
+  public void setSharedStorage(SharedStorage sharedStorage) {
+    this.sharedStorage = sharedStorage;
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/shared/ZookeeperRefDbConfig.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/shared/ZookeeperRefDbConfig.java
new file mode 100644
index 0000000..e90faeb
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/api/model/shared/ZookeeperRefDbConfig.java
@@ -0,0 +1,36 @@
+// Copyright (C) 2023 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.v1alpha.api.model.shared;
+
+public class ZookeeperRefDbConfig {
+  private String connectString;
+  private String rootNode;
+
+  public String getConnectString() {
+    return connectString;
+  }
+
+  public void setConnectString(String connectString) {
+    this.connectString = connectString;
+  }
+
+  public String getRootNode() {
+    return rootNode;
+  }
+
+  public void setRootNode(String rootNode) {
+    this.rootNode = rootNode;
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/gerrit/config/GerritConfigBuilder.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/gerrit/config/GerritConfigBuilder.java
new file mode 100644
index 0000000..221704d
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/gerrit/config/GerritConfigBuilder.java
@@ -0,0 +1,172 @@
+// 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.v1alpha.gerrit.config;
+
+import static com.google.gerrit.k8s.operator.gerrit.dependent.GerritStatefulSet.HTTP_PORT;
+import static com.google.gerrit.k8s.operator.gerrit.dependent.GerritStatefulSet.SSH_PORT;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.k8s.operator.gerrit.config.ConfigBuilder;
+import com.google.gerrit.k8s.operator.gerrit.config.RequiredOption;
+import com.google.gerrit.k8s.operator.gerrit.dependent.GerritService;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.gerrit.Gerrit;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.gerrit.GerritTemplateSpec.GerritMode;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.shared.IngressConfig;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+public class GerritConfigBuilder extends ConfigBuilder {
+  private static final Pattern PROTOCOL_PATTERN = Pattern.compile("^(https?)://.+");
+
+  public GerritConfigBuilder(Gerrit gerrit) {
+    super(
+        gerrit.getSpec().getConfigFiles().getOrDefault("gerrit.config", ""),
+        ImmutableList.copyOf(collectRequiredOptions(gerrit)));
+  }
+
+  private static List<RequiredOption<?>> collectRequiredOptions(Gerrit gerrit) {
+    List<RequiredOption<?>> requiredOptions = new ArrayList<>();
+    requiredOptions.addAll(cacheSection(gerrit));
+    requiredOptions.addAll(containerSection(gerrit));
+    requiredOptions.addAll(gerritSection(gerrit));
+    requiredOptions.addAll(httpdSection(gerrit));
+    requiredOptions.addAll(sshdSection(gerrit));
+    return requiredOptions;
+  }
+
+  private static List<RequiredOption<?>> cacheSection(Gerrit gerrit) {
+    List<RequiredOption<?>> requiredOptions = new ArrayList<>();
+    requiredOptions.add(new RequiredOption<String>("cache", "directory", "cache"));
+    return requiredOptions;
+  }
+
+  private static List<RequiredOption<?>> containerSection(Gerrit gerrit) {
+    List<RequiredOption<?>> requiredOptions = new ArrayList<>();
+    requiredOptions.add(new RequiredOption<String>("container", "user", "gerrit"));
+    requiredOptions.add(
+        new RequiredOption<Boolean>(
+            "container", "replica", gerrit.getSpec().getMode().equals(GerritMode.REPLICA)));
+    requiredOptions.add(
+        new RequiredOption<String>("container", "javaHome", "/usr/lib/jvm/java-11-openjdk"));
+    requiredOptions.add(javaOptions(gerrit));
+    return requiredOptions;
+  }
+
+  private static List<RequiredOption<?>> gerritSection(Gerrit gerrit) {
+    List<RequiredOption<?>> requiredOptions = new ArrayList<>();
+    String serverId = gerrit.getSpec().getServerId();
+    requiredOptions.add(new RequiredOption<String>("gerrit", "basepath", "git"));
+    if (serverId != null && !serverId.isBlank()) {
+      requiredOptions.add(new RequiredOption<String>("gerrit", "serverId", serverId));
+    }
+
+    if (gerrit.getSpec().isHighlyAvailablePrimary()) {
+      requiredOptions.add(
+          new RequiredOption<Set<String>>(
+              "gerrit",
+              "installModule",
+              Set.of("com.gerritforge.gerrit.globalrefdb.validation.LibModule")));
+      requiredOptions.add(
+          new RequiredOption<Set<String>>(
+              "gerrit",
+              "installDbModule",
+              Set.of("com.ericsson.gerrit.plugins.highavailability.ValidationModule")));
+    }
+
+    IngressConfig ingressConfig = gerrit.getSpec().getIngress();
+    if (ingressConfig.isEnabled()) {
+      requiredOptions.add(
+          new RequiredOption<String>("gerrit", "canonicalWebUrl", ingressConfig.getUrl()));
+    }
+
+    return requiredOptions;
+  }
+
+  private static List<RequiredOption<?>> httpdSection(Gerrit gerrit) {
+    List<RequiredOption<?>> requiredOptions = new ArrayList<>();
+    IngressConfig ingressConfig = gerrit.getSpec().getIngress();
+    if (ingressConfig.isEnabled()) {
+      requiredOptions.add(listenUrl(ingressConfig.getUrl()));
+    }
+    return requiredOptions;
+  }
+
+  private static List<RequiredOption<?>> sshdSection(Gerrit gerrit) {
+    List<RequiredOption<?>> requiredOptions = new ArrayList<>();
+    requiredOptions.add(sshListenAddress(gerrit));
+    IngressConfig ingressConfig = gerrit.getSpec().getIngress();
+    if (ingressConfig.isEnabled() && gerrit.isSshEnabled()) {
+      requiredOptions.add(sshAdvertisedAddress(gerrit));
+    }
+    return requiredOptions;
+  }
+
+  private static RequiredOption<Set<String>> javaOptions(Gerrit gerrit) {
+    Set<String> javaOptions = new HashSet<>();
+    javaOptions.add("-Djavax.net.ssl.trustStore=/var/gerrit/etc/keystore");
+    if (gerrit.getSpec().isHighlyAvailablePrimary()) {
+      javaOptions.add("-Djava.net.preferIPv4Stack=true");
+    }
+    if (gerrit.getSpec().getDebug().isEnabled()) {
+      javaOptions.add("-Xdebug");
+      String debugServerCfg = "-Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000";
+      if (gerrit.getSpec().getDebug().isSuspend()) {
+        debugServerCfg = debugServerCfg + ",suspend=y";
+      } else {
+        debugServerCfg = debugServerCfg + ",suspend=n";
+      }
+      javaOptions.add(debugServerCfg);
+    }
+    return new RequiredOption<Set<String>>("container", "javaOptions", javaOptions);
+  }
+
+  private static RequiredOption<String> listenUrl(String url) {
+    StringBuilder listenUrlBuilder = new StringBuilder();
+    listenUrlBuilder.append("proxy-");
+    Matcher protocolMatcher = PROTOCOL_PATTERN.matcher(url);
+    if (protocolMatcher.matches()) {
+      listenUrlBuilder.append(protocolMatcher.group(1));
+    } else {
+      throw new IllegalStateException(
+          String.format("Unknown protocol used for canonicalWebUrl: %s", url));
+    }
+    listenUrlBuilder.append("://*:");
+    listenUrlBuilder.append(HTTP_PORT);
+    listenUrlBuilder.append("/");
+    return new RequiredOption<String>("httpd", "listenUrl", listenUrlBuilder.toString());
+  }
+
+  private static RequiredOption<String> sshListenAddress(Gerrit gerrit) {
+    String listenAddress;
+    if (gerrit.isSshEnabled()) {
+      listenAddress = "*:" + SSH_PORT;
+    } else {
+      listenAddress = "off";
+    }
+    return new RequiredOption<String>("sshd", "listenAddress", listenAddress);
+  }
+
+  private static RequiredOption<String> sshAdvertisedAddress(Gerrit gerrit) {
+    return new RequiredOption<String>(
+        "sshd",
+        "advertisedAddress",
+        gerrit.getSpec().getIngress().getFullHostnameForService(GerritService.getName(gerrit))
+            + ":29418");
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/gerrit/config/HighAvailabilityPluginConfigBuilder.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/gerrit/config/HighAvailabilityPluginConfigBuilder.java
new file mode 100644
index 0000000..b96a93b
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/gerrit/config/HighAvailabilityPluginConfigBuilder.java
@@ -0,0 +1,65 @@
+// Copyright (C) 2023 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.v1alpha.gerrit.config;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.k8s.operator.gerrit.config.ConfigBuilder;
+import com.google.gerrit.k8s.operator.gerrit.config.RequiredOption;
+import com.google.gerrit.k8s.operator.gerrit.dependent.GerritStatefulSet;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.gerrit.Gerrit;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+public class HighAvailabilityPluginConfigBuilder extends ConfigBuilder {
+  public HighAvailabilityPluginConfigBuilder(Gerrit gerrit) {
+    super(
+        gerrit.getSpec().getConfigFiles().getOrDefault("high-availability.config", ""),
+        ImmutableList.copyOf(collectRequiredOptions(gerrit)));
+  }
+
+  private static List<RequiredOption<?>> collectRequiredOptions(Gerrit gerrit) {
+    List<RequiredOption<?>> requiredOptions = new ArrayList<>();
+    requiredOptions.add(new RequiredOption<String>("main", "sharedDirectory", "shared"));
+    requiredOptions.add(new RequiredOption<String>("peerInfo", "strategy", "jgroups"));
+    requiredOptions.add(new RequiredOption<String>("peerInfo", "jgroups", "myUrl", null));
+    requiredOptions.add(
+        new RequiredOption<String>("jgroups", "clusterName", gerrit.getMetadata().getName()));
+    requiredOptions.add(new RequiredOption<Boolean>("jgroups", "kubernetes", true));
+    requiredOptions.add(
+        new RequiredOption<String>(
+            "jgroups", "kubernetes", "namespace", gerrit.getMetadata().getNamespace()));
+    requiredOptions.add(
+        new RequiredOption<Set<String>>("jgroups", "kubernetes", "label", getLabels(gerrit)));
+    requiredOptions.add(new RequiredOption<Boolean>("cache", "synchronize", true));
+    requiredOptions.add(new RequiredOption<Boolean>("event", "synchronize", true));
+    requiredOptions.add(new RequiredOption<Boolean>("index", "synchronize", true));
+    requiredOptions.add(new RequiredOption<Boolean>("index", "synchronizeForced", true));
+    requiredOptions.add(new RequiredOption<Boolean>("healthcheck", "enable", true));
+    requiredOptions.add(new RequiredOption<Boolean>("ref-database", "enabled", true));
+    return requiredOptions;
+  }
+
+  private static Set<String> getLabels(Gerrit gerrit) {
+    Map<String, String> selectorLabels = GerritStatefulSet.getSelectorLabels(gerrit);
+    Set<String> labels = new HashSet<>();
+    for (Map.Entry<String, String> label : selectorLabels.entrySet()) {
+      labels.add(label.getKey() + "=" + label.getValue());
+    }
+    return labels;
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/gerrit/config/SpannerRefDbPluginConfigBuilder.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/gerrit/config/SpannerRefDbPluginConfigBuilder.java
new file mode 100644
index 0000000..0f3cb30
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/gerrit/config/SpannerRefDbPluginConfigBuilder.java
@@ -0,0 +1,58 @@
+// Copyright (C) 2023 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.v1alpha.gerrit.config;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.k8s.operator.gerrit.config.ConfigBuilder;
+import com.google.gerrit.k8s.operator.gerrit.config.RequiredOption;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.gerrit.Gerrit;
+import java.util.ArrayList;
+import java.util.List;
+
+public class SpannerRefDbPluginConfigBuilder extends ConfigBuilder {
+  public SpannerRefDbPluginConfigBuilder(Gerrit gerrit) {
+    super(
+        gerrit.getSpec().getConfigFiles().getOrDefault("spanner-refdb.config", ""),
+        ImmutableList.copyOf(collectRequiredOptions(gerrit)));
+  }
+
+  private static List<RequiredOption<?>> collectRequiredOptions(Gerrit gerrit) {
+    List<RequiredOption<?>> requiredOptions = new ArrayList<>();
+    requiredOptions.add(
+        new RequiredOption<String>("ref-database", "spanner", "useEmulator", "false"));
+    requiredOptions.add(
+        new RequiredOption<String>(
+            "ref-database",
+            "spanner",
+            "projectName",
+            gerrit.getSpec().getRefdb().getSpanner().getProjectName()));
+    requiredOptions.add(
+        new RequiredOption<String>(
+            "ref-database", "spanner", "credentialsPath", "/var/gerrit/etc/gcp-credentials.json"));
+    requiredOptions.add(
+        new RequiredOption<String>(
+            "ref-database",
+            "spanner",
+            "instance",
+            gerrit.getSpec().getRefdb().getSpanner().getInstance()));
+    requiredOptions.add(
+        new RequiredOption<String>(
+            "ref-database",
+            "spanner",
+            "database",
+            gerrit.getSpec().getRefdb().getSpanner().getDatabase()));
+    return requiredOptions;
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/gerrit/config/ZookeeperRefDbPluginConfigBuilder.java b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/gerrit/config/ZookeeperRefDbPluginConfigBuilder.java
new file mode 100644
index 0000000..aabb726
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/java/com/google/gerrit/k8s/operator/v1alpha/gerrit/config/ZookeeperRefDbPluginConfigBuilder.java
@@ -0,0 +1,47 @@
+// Copyright (C) 2023 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.v1alpha.gerrit.config;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.k8s.operator.gerrit.config.ConfigBuilder;
+import com.google.gerrit.k8s.operator.gerrit.config.RequiredOption;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.gerrit.Gerrit;
+import java.util.ArrayList;
+import java.util.List;
+
+public class ZookeeperRefDbPluginConfigBuilder extends ConfigBuilder {
+  public ZookeeperRefDbPluginConfigBuilder(Gerrit gerrit) {
+    super(
+        gerrit.getSpec().getConfigFiles().getOrDefault("zookeeper-refdb.config", ""),
+        ImmutableList.copyOf(collectRequiredOptions(gerrit)));
+  }
+
+  private static List<RequiredOption<?>> collectRequiredOptions(Gerrit gerrit) {
+    List<RequiredOption<?>> requiredOptions = new ArrayList<>();
+    requiredOptions.add(
+        new RequiredOption<String>(
+            "ref-database",
+            "zookeeper",
+            "connectString",
+            gerrit.getSpec().getRefdb().getZookeeper().getConnectString()));
+    requiredOptions.add(
+        new RequiredOption<String>(
+            "ref-database",
+            "zookeeper",
+            "rootNode",
+            gerrit.getSpec().getRefdb().getZookeeper().getRootNode()));
+    return requiredOptions;
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/main/resources/META-INF/services/io.fabric8.kubernetes.api.model.KubernetesResource b/charts/k8s-gerrit/operator/src/main/resources/META-INF/services/io.fabric8.kubernetes.api.model.KubernetesResource
new file mode 100644
index 0000000..6fb15ac
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/resources/META-INF/services/io.fabric8.kubernetes.api.model.KubernetesResource
@@ -0,0 +1,5 @@
+com.google.gerrit.k8s.operator.v1alpha.api.model.cluster.GerritCluster
+com.google.gerrit.k8s.operator.v1alpha.api.model.gerrit.Gerrit
+com.google.gerrit.k8s.operator.v1alpha.api.model.gitgc.GitGarbageCollection
+com.google.gerrit.k8s.operator.v1alpha.api.model.receiver.Receiver
+com.google.gerrit.k8s.operator.v1alpha.api.model.network.GerritNetwork
\ No newline at end of file
diff --git a/charts/k8s-gerrit/operator/src/main/resources/crd/emissary-crds.yaml b/charts/k8s-gerrit/operator/src/main/resources/crd/emissary-crds.yaml
new file mode 100644
index 0000000..bba936f
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/resources/crd/emissary-crds.yaml
@@ -0,0 +1,2589 @@
+# This file is downloaded from the Emissary repository on GitHub:
+# https://github.com/emissary-ingress/emissary/blob/master/manifests/emissary/emissary-crds.yaml.in
+#
+# Several modifications have been manually made:
+# 1. Only the `Mapping`, `TLSContext`, and `Host` CRDs have been kept from the source file. The source
+#    file defines many CRDs that are not required by this operator project so the unnecessary CRDs have
+#    been deleted.
+# 2. `v2ExplicitTLS` field has been removed from the Mapping CRD `v3alpha1` version. This is because
+#    the "crd-to-java" generator plugin we use has a bug (https://github.com/fabric8io/kubernetes-client/issues/5457)
+#    while converting enum types and the bug is triggered by the `v2ExplicitTLS` field. This field
+#    may be added back in once we upgrade our fabric8 version to 6.8.x, where this bug is resolved.
+# 3. `ambassador_id` property is added to `Mapping`, `TLSContext`, and `Host` CRD version `v2`, by
+#     copying it over from `v3`.
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  annotations:
+    controller-gen.kubebuilder.io/version: v0.12.0
+  labels:
+    app.kubernetes.io/instance: emissary-apiext
+    app.kubernetes.io/managed-by: kubectl_apply_-f_emissary-apiext.yaml
+    app.kubernetes.io/name: emissary-apiext
+    app.kubernetes.io/part-of: emissary-apiext
+  name: mappings.getambassador.io
+spec:
+  conversion:
+    strategy: Webhook
+    webhook:
+      clientConfig:
+        service:
+          name: emissary-apiext
+          namespace: emissary-system
+      conversionReviewVersions:
+      - v1
+  group: getambassador.io
+  names:
+    categories:
+    - ambassador-crds
+    kind: Mapping
+    listKind: MappingList
+    plural: mappings
+    singular: mapping
+  preserveUnknownFields: false
+  scope: Namespaced
+  versions:
+  - additionalPrinterColumns:
+    - jsonPath: .spec.host
+      name: Source Host
+      type: string
+    - jsonPath: .spec.prefix
+      name: Source Prefix
+      type: string
+    - jsonPath: .spec.service
+      name: Dest Service
+      type: string
+    - jsonPath: .status.state
+      name: State
+      type: string
+    - jsonPath: .status.reason
+      name: Reason
+      type: string
+    name: v1
+    schema:
+      openAPIV3Schema:
+        description: Mapping is the Schema for the mappings API
+        properties:
+          apiVersion:
+            description: 'APIVersion defines the versioned schema of this representation
+              of an object. Servers should convert recognized schemas to the latest
+              internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources'
+            type: string
+          kind:
+            description: 'Kind is a string value representing the REST resource this
+              object represents. Servers may infer this from the endpoint the client
+              submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
+            type: string
+          metadata:
+            type: object
+          spec:
+            description: MappingSpec defines the desired state of Mapping
+            properties:
+              add_linkerd_headers:
+                type: boolean
+              add_request_headers:
+                type: object
+                x-kubernetes-preserve-unknown-fields: true
+              add_response_headers:
+                type: object
+                x-kubernetes-preserve-unknown-fields: true
+              allow_upgrade:
+                description: "A case-insensitive list of the non-HTTP protocols to
+                  allow \"upgrading\" to from HTTP via the \"Connection: upgrade\"
+                  mechanism[1].  After the upgrade, Ambassador does not interpret
+                  the traffic, and behaves similarly to how it does for TCPMappings.
+                  \n [1]: https://tools.ietf.org/html/rfc7230#section-6.7 \n For example,
+                  if your upstream service supports WebSockets, you would write \n
+                  allow_upgrade: - websocket \n Or if your upstream service supports
+                  upgrading from HTTP to SPDY (as the Kubernetes apiserver does for
+                  `kubectl exec` functionality), you would write \n allow_upgrade:
+                  - spdy/3.1"
+                items:
+                  type: string
+                type: array
+              auth_context_extensions:
+                additionalProperties:
+                  type: string
+                type: object
+              auto_host_rewrite:
+                type: boolean
+              bypass_auth:
+                type: boolean
+              bypass_error_response_overrides:
+                description: If true, bypasses any `error_response_overrides` set
+                  on the Ambassador module.
+                type: boolean
+              case_sensitive:
+                type: boolean
+              circuit_breakers:
+                items:
+                  properties:
+                    max_connections:
+                      type: integer
+                    max_pending_requests:
+                      type: integer
+                    max_requests:
+                      type: integer
+                    max_retries:
+                      type: integer
+                    priority:
+                      enum:
+                      - default
+                      - high
+                      type: string
+                  type: object
+                type: array
+              cluster_idle_timeout_ms:
+                type: integer
+              cluster_max_connection_lifetime_ms:
+                type: integer
+              cluster_tag:
+                type: string
+              connect_timeout_ms:
+                type: integer
+              cors:
+                properties:
+                  credentials:
+                    type: boolean
+                  max_age:
+                    type: string
+                type: object
+                x-kubernetes-preserve-unknown-fields: true
+              dns_type:
+                type: string
+              docs:
+                description: DocsInfo provides some extra information about the docs
+                  for the Mapping (used by the Dev Portal)
+                properties:
+                  display_name:
+                    type: string
+                  ignored:
+                    type: boolean
+                  path:
+                    type: string
+                  timeout_ms:
+                    type: integer
+                  url:
+                    type: string
+                type: object
+              enable_ipv4:
+                type: boolean
+              enable_ipv6:
+                type: boolean
+              envoy_override:
+                type: object
+                x-kubernetes-preserve-unknown-fields: true
+              error_response_overrides:
+                description: Error response overrides for this Mapping. Replaces all
+                  of the `error_response_overrides` set on the Ambassador module,
+                  if any.
+                items:
+                  description: A response rewrite for an HTTP error response
+                  properties:
+                    body:
+                      description: The new response body
+                      properties:
+                        content_type:
+                          description: The content type to set on the error response
+                            body when using text_format or text_format_source. Defaults
+                            to 'text/plain'.
+                          type: string
+                        json_format:
+                          additionalProperties:
+                            type: string
+                          description: 'A JSON response with content-type: application/json.
+                            The values can contain format text like in text_format.'
+                          type: object
+                        text_format:
+                          description: A format string representing a text response
+                            body. Content-Type can be set using the `content_type`
+                            field below.
+                          type: string
+                        text_format_source:
+                          description: A format string sourced from a file on the
+                            Ambassador container. Useful for larger response bodies
+                            that should not be placed inline in configuration.
+                          properties:
+                            filename:
+                              description: The name of a file on the Ambassador pod
+                                that contains a format text string.
+                              type: string
+                          type: object
+                      type: object
+                    on_status_code:
+                      description: The status code to match on -- not a pointer because
+                        it's required.
+                      maximum: 599
+                      minimum: 400
+                      type: integer
+                  required:
+                  - body
+                  - on_status_code
+                  type: object
+                minItems: 1
+                type: array
+              grpc:
+                type: boolean
+              headers:
+                type: object
+                x-kubernetes-preserve-unknown-fields: true
+              host:
+                type: string
+              host_redirect:
+                type: boolean
+              host_regex:
+                type: boolean
+              host_rewrite:
+                type: string
+              idle_timeout_ms:
+                type: integer
+              keepalive:
+                properties:
+                  idle_time:
+                    type: integer
+                  interval:
+                    type: integer
+                  probes:
+                    type: integer
+                type: object
+              labels:
+                additionalProperties:
+                  description: A MappingLabelGroupsArray is an array of MappingLabelGroups.
+                    I know, complex.
+                  items:
+                    description: 'A MappingLabelGroup is a single element of a MappingLabelGroupsArray:
+                      a second map, where the key is a human-readable name that identifies
+                      the group.'
+                    maxProperties: 1
+                    minProperties: 1
+                    type: object
+                    x-kubernetes-preserve-unknown-fields: true
+                  type: array
+                description: A DomainMap is the overall Mapping.spec.Labels type.
+                  It maps domains (kind of like namespaces for Mapping labels) to
+                  arrays of label groups.
+                type: object
+              load_balancer:
+                properties:
+                  cookie:
+                    properties:
+                      name:
+                        type: string
+                      path:
+                        type: string
+                      ttl:
+                        type: string
+                    required:
+                    - name
+                    type: object
+                  header:
+                    type: string
+                  policy:
+                    enum:
+                    - round_robin
+                    - ring_hash
+                    - maglev
+                    - least_request
+                    type: string
+                  source_ip:
+                    type: boolean
+                required:
+                - policy
+                type: object
+              method:
+                type: string
+              method_regex:
+                type: boolean
+              modules:
+                items:
+                  type: object
+                  x-kubernetes-preserve-unknown-fields: true
+                type: array
+              outlier_detection:
+                type: string
+              path_redirect:
+                description: Path replacement to use when generating an HTTP redirect.
+                  Used with `host_redirect`.
+                type: string
+              precedence:
+                type: integer
+              prefix:
+                type: string
+              prefix_exact:
+                type: boolean
+              prefix_redirect:
+                description: Prefix rewrite to use when generating an HTTP redirect.
+                  Used with `host_redirect`.
+                type: string
+              prefix_regex:
+                type: boolean
+              priority:
+                type: string
+              query_parameters:
+                type: object
+                x-kubernetes-preserve-unknown-fields: true
+              redirect_response_code:
+                description: The response code to use when generating an HTTP redirect.
+                  Defaults to 301. Used with `host_redirect`.
+                enum:
+                - 301
+                - 302
+                - 303
+                - 307
+                - 308
+                type: integer
+              regex_headers:
+                additionalProperties:
+                  type: string
+                type: object
+              regex_query_parameters:
+                additionalProperties:
+                  type: string
+                type: object
+              regex_redirect:
+                description: Prefix regex rewrite to use when generating an HTTP redirect.
+                  Used with `host_redirect`.
+                properties:
+                  pattern:
+                    type: string
+                  substitution:
+                    type: string
+                type: object
+              regex_rewrite:
+                properties:
+                  pattern:
+                    type: string
+                  substitution:
+                    type: string
+                type: object
+              resolver:
+                type: string
+              respect_dns_ttl:
+                type: boolean
+              retry_policy:
+                properties:
+                  num_retries:
+                    type: integer
+                  per_try_timeout:
+                    type: string
+                  retry_on:
+                    enum:
+                    - 5xx
+                    - gateway-error
+                    - connect-failure
+                    - retriable-4xx
+                    - refused-stream
+                    - retriable-status-codes
+                    type: string
+                type: object
+              rewrite:
+                type: string
+              service:
+                type: string
+              shadow:
+                type: boolean
+              timeout_ms:
+                description: The timeout for requests that use this Mapping. Overrides
+                  `cluster_request_timeout_ms` set on the Ambassador Module, if it
+                  exists.
+                type: integer
+              use_websocket:
+                description: 'use_websocket is deprecated, and is equivlaent to setting
+                  `allow_upgrade: ["websocket"]`'
+                type: boolean
+              v3StatsName:
+                type: string
+              v3health_checks:
+                items:
+                  description: HealthCheck specifies settings for performing active
+                    health checking on upstreams
+                  properties:
+                    health_check:
+                      description: Configuration for where the healthcheck request
+                        should be made to
+                      maxProperties: 1
+                      minProperties: 1
+                      properties:
+                        grpc:
+                          description: HealthCheck for gRPC upstreams. Only one of
+                            grpc_health_check or http_health_check may be specified
+                          properties:
+                            authority:
+                              description: The value of the :authority header in the
+                                gRPC health check request. If left empty the upstream
+                                name will be used.
+                              type: string
+                            upstream_name:
+                              description: The upstream name parameter which will
+                                be sent to gRPC service in the health check message
+                              type: string
+                          required:
+                          - upstream_name
+                          type: object
+                        http:
+                          description: HealthCheck for HTTP upstreams. Only one of
+                            http_health_check or grpc_health_check may be specified
+                          properties:
+                            add_request_headers:
+                              additionalProperties:
+                                properties:
+                                  append:
+                                    type: boolean
+                                  v2Representation:
+                                    enum:
+                                    - ""
+                                    - string
+                                    - "null"
+                                    type: string
+                                  value:
+                                    type: string
+                                type: object
+                              type: object
+                            expected_statuses:
+                              items:
+                                description: A range of response statuses from Start
+                                  to End inclusive
+                                properties:
+                                  max:
+                                    description: End of the statuses to include. Must
+                                      be between 100 and 599 (inclusive)
+                                    maximum: 599
+                                    minimum: 100
+                                    type: integer
+                                  min:
+                                    description: Start of the statuses to include.
+                                      Must be between 100 and 599 (inclusive)
+                                    maximum: 599
+                                    minimum: 100
+                                    type: integer
+                                required:
+                                - max
+                                - min
+                                type: object
+                              type: array
+                            hostname:
+                              type: string
+                            path:
+                              type: string
+                            remove_request_headers:
+                              items:
+                                type: string
+                              type: array
+                          required:
+                          - path
+                          type: object
+                      type: object
+                    healthy_threshold:
+                      description: Number of expected responses for the upstream to
+                        be considered healthy. Defaults to 1.
+                      type: integer
+                    interval:
+                      description: Interval between health checks. Defaults to every
+                        5 seconds.
+                      type: string
+                    timeout:
+                      description: Timeout for connecting to the health checking endpoint.
+                        Defaults to 3 seconds.
+                      type: string
+                    unhealthy_threshold:
+                      description: Number of non-expected responses for the upstream
+                        to be considered unhealthy. A single 503 will mark the upstream
+                        as unhealthy regardless of the threshold. Defaults to 2.
+                      type: integer
+                  required:
+                  - health_check
+                  type: object
+                minItems: 1
+                type: array
+              weight:
+                type: integer
+            required:
+            - prefix
+            - service
+            type: object
+            x-kubernetes-preserve-unknown-fields: true
+          status:
+            description: MappingStatus defines the observed state of Mapping
+            properties:
+              reason:
+                type: string
+              state:
+                enum:
+                - ""
+                - Inactive
+                - Running
+                type: string
+            type: object
+        type: object
+    served: true
+    storage: false
+    subresources:
+      status: {}
+  - additionalPrinterColumns:
+    - jsonPath: .spec.host
+      name: Source Host
+      type: string
+    - jsonPath: .spec.prefix
+      name: Source Prefix
+      type: string
+    - jsonPath: .spec.service
+      name: Dest Service
+      type: string
+    - jsonPath: .status.state
+      name: State
+      type: string
+    - jsonPath: .status.reason
+      name: Reason
+      type: string
+    name: v2
+    schema:
+      openAPIV3Schema:
+        description: Mapping is the Schema for the mappings API
+        properties:
+          apiVersion:
+            description: 'APIVersion defines the versioned schema of this representation
+              of an object. Servers should convert recognized schemas to the latest
+              internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources'
+            type: string
+          kind:
+            description: 'Kind is a string value representing the REST resource this
+              object represents. Servers may infer this from the endpoint the client
+              submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
+            type: string
+          metadata:
+            type: object
+          spec:
+            description: MappingSpec defines the desired state of Mapping
+            properties:
+              add_linkerd_headers:
+                type: boolean
+              add_request_headers:
+                type: object
+                x-kubernetes-preserve-unknown-fields: true
+              add_response_headers:
+                type: object
+                x-kubernetes-preserve-unknown-fields: true
+              allow_upgrade:
+                description: "A case-insensitive list of the non-HTTP protocols to
+                  allow \"upgrading\" to from HTTP via the \"Connection: upgrade\"
+                  mechanism[1].  After the upgrade, Ambassador does not interpret
+                  the traffic, and behaves similarly to how it does for TCPMappings.
+                  \n [1]: https://tools.ietf.org/html/rfc7230#section-6.7 \n For example,
+                  if your upstream service supports WebSockets, you would write \n
+                  allow_upgrade: - websocket \n Or if your upstream service supports
+                  upgrading from HTTP to SPDY (as the Kubernetes apiserver does for
+                  `kubectl exec` functionality), you would write \n allow_upgrade:
+                  - spdy/3.1"
+                items:
+                  type: string
+                type: array
+              # [operator] added manually by coping over from v3alpha1
+              ambassador_id:
+                description: "AmbassadorID declares which Ambassador instances should
+                  pay attention to this resource. If no value is provided, the default
+                  is: \n ambassador_id: - \"default\""
+                items:
+                  type: string
+                type: array
+              auth_context_extensions:
+                additionalProperties:
+                  type: string
+                type: object
+              auto_host_rewrite:
+                type: boolean
+              bypass_auth:
+                type: boolean
+              bypass_error_response_overrides:
+                description: If true, bypasses any `error_response_overrides` set
+                  on the Ambassador module.
+                type: boolean
+              case_sensitive:
+                type: boolean
+              circuit_breakers:
+                items:
+                  properties:
+                    max_connections:
+                      type: integer
+                    max_pending_requests:
+                      type: integer
+                    max_requests:
+                      type: integer
+                    max_retries:
+                      type: integer
+                    priority:
+                      enum:
+                      - default
+                      - high
+                      type: string
+                  type: object
+                type: array
+              cluster_idle_timeout_ms:
+                type: integer
+              cluster_max_connection_lifetime_ms:
+                type: integer
+              cluster_tag:
+                type: string
+              connect_timeout_ms:
+                type: integer
+              cors:
+                properties:
+                  credentials:
+                    type: boolean
+                  max_age:
+                    type: string
+                type: object
+                x-kubernetes-preserve-unknown-fields: true
+              dns_type:
+                type: string
+              docs:
+                description: DocsInfo provides some extra information about the docs
+                  for the Mapping (used by the Dev Portal)
+                properties:
+                  display_name:
+                    type: string
+                  ignored:
+                    type: boolean
+                  path:
+                    type: string
+                  timeout_ms:
+                    type: integer
+                  url:
+                    type: string
+                type: object
+              enable_ipv4:
+                type: boolean
+              enable_ipv6:
+                type: boolean
+              envoy_override:
+                type: object
+                x-kubernetes-preserve-unknown-fields: true
+              error_response_overrides:
+                description: Error response overrides for this Mapping. Replaces all
+                  of the `error_response_overrides` set on the Ambassador module,
+                  if any.
+                items:
+                  description: A response rewrite for an HTTP error response
+                  properties:
+                    body:
+                      description: The new response body
+                      properties:
+                        content_type:
+                          description: The content type to set on the error response
+                            body when using text_format or text_format_source. Defaults
+                            to 'text/plain'.
+                          type: string
+                        json_format:
+                          additionalProperties:
+                            type: string
+                          description: 'A JSON response with content-type: application/json.
+                            The values can contain format text like in text_format.'
+                          type: object
+                        text_format:
+                          description: A format string representing a text response
+                            body. Content-Type can be set using the `content_type`
+                            field below.
+                          type: string
+                        text_format_source:
+                          description: A format string sourced from a file on the
+                            Ambassador container. Useful for larger response bodies
+                            that should not be placed inline in configuration.
+                          properties:
+                            filename:
+                              description: The name of a file on the Ambassador pod
+                                that contains a format text string.
+                              type: string
+                          type: object
+                      type: object
+                    on_status_code:
+                      description: The status code to match on -- not a pointer because
+                        it's required.
+                      maximum: 599
+                      minimum: 400
+                      type: integer
+                  required:
+                  - body
+                  - on_status_code
+                  type: object
+                minItems: 1
+                type: array
+              grpc:
+                type: boolean
+              headers:
+                type: object
+                x-kubernetes-preserve-unknown-fields: true
+              host:
+                type: string
+              host_redirect:
+                type: boolean
+              host_regex:
+                type: boolean
+              host_rewrite:
+                type: string
+              idle_timeout_ms:
+                type: integer
+              keepalive:
+                properties:
+                  idle_time:
+                    type: integer
+                  interval:
+                    type: integer
+                  probes:
+                    type: integer
+                type: object
+              labels:
+                additionalProperties:
+                  description: A MappingLabelGroupsArray is an array of MappingLabelGroups.
+                    I know, complex.
+                  items:
+                    description: 'A MappingLabelGroup is a single element of a MappingLabelGroupsArray:
+                      a second map, where the key is a human-readable name that identifies
+                      the group.'
+                    maxProperties: 1
+                    minProperties: 1
+                    type: object
+                    x-kubernetes-preserve-unknown-fields: true
+                  type: array
+                description: A DomainMap is the overall Mapping.spec.Labels type.
+                  It maps domains (kind of like namespaces for Mapping labels) to
+                  arrays of label groups.
+                type: object
+              load_balancer:
+                properties:
+                  cookie:
+                    properties:
+                      name:
+                        type: string
+                      path:
+                        type: string
+                      ttl:
+                        type: string
+                    required:
+                    - name
+                    type: object
+                  header:
+                    type: string
+                  policy:
+                    enum:
+                    - round_robin
+                    - ring_hash
+                    - maglev
+                    - least_request
+                    type: string
+                  source_ip:
+                    type: boolean
+                required:
+                - policy
+                type: object
+              method:
+                type: string
+              method_regex:
+                type: boolean
+              modules:
+                items:
+                  type: object
+                  x-kubernetes-preserve-unknown-fields: true
+                type: array
+              outlier_detection:
+                type: string
+              path_redirect:
+                description: Path replacement to use when generating an HTTP redirect.
+                  Used with `host_redirect`.
+                type: string
+              precedence:
+                type: integer
+              prefix:
+                type: string
+              prefix_exact:
+                type: boolean
+              prefix_redirect:
+                description: Prefix rewrite to use when generating an HTTP redirect.
+                  Used with `host_redirect`.
+                type: string
+              prefix_regex:
+                type: boolean
+              priority:
+                type: string
+              query_parameters:
+                type: object
+                x-kubernetes-preserve-unknown-fields: true
+              redirect_response_code:
+                description: The response code to use when generating an HTTP redirect.
+                  Defaults to 301. Used with `host_redirect`.
+                enum:
+                - 301
+                - 302
+                - 303
+                - 307
+                - 308
+                type: integer
+              regex_headers:
+                additionalProperties:
+                  type: string
+                type: object
+              regex_query_parameters:
+                additionalProperties:
+                  type: string
+                type: object
+              regex_redirect:
+                description: Prefix regex rewrite to use when generating an HTTP redirect.
+                  Used with `host_redirect`.
+                properties:
+                  pattern:
+                    type: string
+                  substitution:
+                    type: string
+                type: object
+              regex_rewrite:
+                properties:
+                  pattern:
+                    type: string
+                  substitution:
+                    type: string
+                type: object
+              resolver:
+                type: string
+              respect_dns_ttl:
+                type: boolean
+              retry_policy:
+                properties:
+                  num_retries:
+                    type: integer
+                  per_try_timeout:
+                    type: string
+                  retry_on:
+                    enum:
+                    - 5xx
+                    - gateway-error
+                    - connect-failure
+                    - retriable-4xx
+                    - refused-stream
+                    - retriable-status-codes
+                    type: string
+                type: object
+              rewrite:
+                type: string
+              service:
+                type: string
+              shadow:
+                type: boolean
+              timeout_ms:
+                description: The timeout for requests that use this Mapping. Overrides
+                  `cluster_request_timeout_ms` set on the Ambassador Module, if it
+                  exists.
+                type: integer
+              use_websocket:
+                description: 'use_websocket is deprecated, and is equivlaent to setting
+                  `allow_upgrade: ["websocket"]`'
+                type: boolean
+              v3StatsName:
+                type: string
+              v3health_checks:
+                items:
+                  description: HealthCheck specifies settings for performing active
+                    health checking on upstreams
+                  properties:
+                    health_check:
+                      description: Configuration for where the healthcheck request
+                        should be made to
+                      maxProperties: 1
+                      minProperties: 1
+                      properties:
+                        grpc:
+                          description: HealthCheck for gRPC upstreams. Only one of
+                            grpc_health_check or http_health_check may be specified
+                          properties:
+                            authority:
+                              description: The value of the :authority header in the
+                                gRPC health check request. If left empty the upstream
+                                name will be used.
+                              type: string
+                            upstream_name:
+                              description: The upstream name parameter which will
+                                be sent to gRPC service in the health check message
+                              type: string
+                          required:
+                          - upstream_name
+                          type: object
+                        http:
+                          description: HealthCheck for HTTP upstreams. Only one of
+                            http_health_check or grpc_health_check may be specified
+                          properties:
+                            add_request_headers:
+                              additionalProperties:
+                                properties:
+                                  append:
+                                    type: boolean
+                                  v2Representation:
+                                    enum:
+                                    - ""
+                                    - string
+                                    - "null"
+                                    type: string
+                                  value:
+                                    type: string
+                                type: object
+                              type: object
+                            expected_statuses:
+                              items:
+                                description: A range of response statuses from Start
+                                  to End inclusive
+                                properties:
+                                  max:
+                                    description: End of the statuses to include. Must
+                                      be between 100 and 599 (inclusive)
+                                    maximum: 599
+                                    minimum: 100
+                                    type: integer
+                                  min:
+                                    description: Start of the statuses to include.
+                                      Must be between 100 and 599 (inclusive)
+                                    maximum: 599
+                                    minimum: 100
+                                    type: integer
+                                required:
+                                - max
+                                - min
+                                type: object
+                              type: array
+                            hostname:
+                              type: string
+                            path:
+                              type: string
+                            remove_request_headers:
+                              items:
+                                type: string
+                              type: array
+                          required:
+                          - path
+                          type: object
+                      type: object
+                    healthy_threshold:
+                      description: Number of expected responses for the upstream to
+                        be considered healthy. Defaults to 1.
+                      type: integer
+                    interval:
+                      description: Interval between health checks. Defaults to every
+                        5 seconds.
+                      type: string
+                    timeout:
+                      description: Timeout for connecting to the health checking endpoint.
+                        Defaults to 3 seconds.
+                      type: string
+                    unhealthy_threshold:
+                      description: Number of non-expected responses for the upstream
+                        to be considered unhealthy. A single 503 will mark the upstream
+                        as unhealthy regardless of the threshold. Defaults to 2.
+                      type: integer
+                  required:
+                  - health_check
+                  type: object
+                minItems: 1
+                type: array
+              weight:
+                type: integer
+            required:
+            - prefix
+            - service
+            type: object
+            x-kubernetes-preserve-unknown-fields: true
+          status:
+            description: MappingStatus defines the observed state of Mapping
+            properties:
+              reason:
+                type: string
+              state:
+                enum:
+                - ""
+                - Inactive
+                - Running
+                type: string
+            type: object
+        type: object
+    served: true
+    storage: true
+    subresources:
+      status: {}
+  - additionalPrinterColumns:
+    - jsonPath: .spec.host
+      name: Source Host
+      type: string
+    - jsonPath: .spec.prefix
+      name: Source Prefix
+      type: string
+    - jsonPath: .spec.service
+      name: Dest Service
+      type: string
+    - jsonPath: .status.state
+      name: State
+      type: string
+    - jsonPath: .status.reason
+      name: Reason
+      type: string
+    name: v3alpha1
+    schema:
+      openAPIV3Schema:
+        description: Mapping is the Schema for the mappings API
+        properties:
+          apiVersion:
+            description: 'APIVersion defines the versioned schema of this representation
+              of an object. Servers should convert recognized schemas to the latest
+              internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources'
+            type: string
+          kind:
+            description: 'Kind is a string value representing the REST resource this
+              object represents. Servers may infer this from the endpoint the client
+              submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
+            type: string
+          metadata:
+            type: object
+          spec:
+            description: MappingSpec defines the desired state of Mapping
+            properties:
+              add_linkerd_headers:
+                type: boolean
+              add_request_headers:
+                additionalProperties:
+                  properties:
+                    append:
+                      type: boolean
+                    v2Representation:
+                      enum:
+                      - ""
+                      - string
+                      - "null"
+                      type: string
+                    value:
+                      type: string
+                  type: object
+                type: object
+              add_response_headers:
+                additionalProperties:
+                  properties:
+                    append:
+                      type: boolean
+                    v2Representation:
+                      enum:
+                      - ""
+                      - string
+                      - "null"
+                      type: string
+                    value:
+                      type: string
+                  type: object
+                type: object
+              allow_upgrade:
+                description: "A case-insensitive list of the non-HTTP protocols to
+                  allow \"upgrading\" to from HTTP via the \"Connection: upgrade\"
+                  mechanism[1].  After the upgrade, Ambassador does not interpret
+                  the traffic, and behaves similarly to how it does for TCPMappings.
+                  \n [1]: https://tools.ietf.org/html/rfc7230#section-6.7 \n For example,
+                  if your upstream service supports WebSockets, you would write \n
+                  allow_upgrade: - websocket \n Or if your upstream service supports
+                  upgrading from HTTP to SPDY (as the Kubernetes apiserver does for
+                  `kubectl exec` functionality), you would write \n allow_upgrade:
+                  - spdy/3.1"
+                items:
+                  type: string
+                type: array
+              ambassador_id:
+                description: "AmbassadorID declares which Ambassador instances should
+                  pay attention to this resource. If no value is provided, the default
+                  is: \n ambassador_id: - \"default\""
+                items:
+                  type: string
+                type: array
+              auth_context_extensions:
+                additionalProperties:
+                  type: string
+                type: object
+              auto_host_rewrite:
+                type: boolean
+              bypass_auth:
+                type: boolean
+              bypass_error_response_overrides:
+                description: If true, bypasses any `error_response_overrides` set
+                  on the Ambassador module.
+                type: boolean
+              case_sensitive:
+                type: boolean
+              circuit_breakers:
+                items:
+                  properties:
+                    max_connections:
+                      type: integer
+                    max_pending_requests:
+                      type: integer
+                    max_requests:
+                      type: integer
+                    max_retries:
+                      type: integer
+                    priority:
+                      enum:
+                      - default
+                      - high
+                      type: string
+                  type: object
+                type: array
+              cluster_idle_timeout_ms:
+                type: integer
+              cluster_max_connection_lifetime_ms:
+                type: integer
+              cluster_tag:
+                type: string
+              connect_timeout_ms:
+                type: integer
+              cors:
+                properties:
+                  credentials:
+                    type: boolean
+                  exposed_headers:
+                    items:
+                      type: string
+                    type: array
+                  headers:
+                    items:
+                      type: string
+                    type: array
+                  max_age:
+                    type: string
+                  methods:
+                    items:
+                      type: string
+                    type: array
+                  origins:
+                    items:
+                      type: string
+                    type: array
+                  v2CommaSeparatedOrigins:
+                    type: boolean
+                type: object
+              dns_type:
+                type: string
+              docs:
+                description: DocsInfo provides some extra information about the docs
+                  for the Mapping. Docs is used by both the agent and the DevPortal.
+                properties:
+                  display_name:
+                    type: string
+                  ignored:
+                    type: boolean
+                  path:
+                    type: string
+                  timeout_ms:
+                    type: integer
+                  url:
+                    type: string
+                type: object
+              enable_ipv4:
+                type: boolean
+              enable_ipv6:
+                type: boolean
+              envoy_override:
+                type: object
+                x-kubernetes-preserve-unknown-fields: true
+              error_response_overrides:
+                description: Error response overrides for this Mapping. Replaces all
+                  of the `error_response_overrides` set on the Ambassador module,
+                  if any.
+                items:
+                  description: A response rewrite for an HTTP error response
+                  properties:
+                    body:
+                      description: The new response body
+                      properties:
+                        content_type:
+                          description: The content type to set on the error response
+                            body when using text_format or text_format_source. Defaults
+                            to 'text/plain'.
+                          type: string
+                        json_format:
+                          additionalProperties:
+                            type: string
+                          description: 'A JSON response with content-type: application/json.
+                            The values can contain format text like in text_format.'
+                          type: object
+                        text_format:
+                          description: A format string representing a text response
+                            body. Content-Type can be set using the `content_type`
+                            field below.
+                          type: string
+                        text_format_source:
+                          description: A format string sourced from a file on the
+                            Ambassador container. Useful for larger response bodies
+                            that should not be placed inline in configuration.
+                          properties:
+                            filename:
+                              description: The name of a file on the Ambassador pod
+                                that contains a format text string.
+                              type: string
+                          type: object
+                      type: object
+                    on_status_code:
+                      description: The status code to match on -- not a pointer because
+                        it's required.
+                      maximum: 599
+                      minimum: 400
+                      type: integer
+                  required:
+                  - body
+                  - on_status_code
+                  type: object
+                minItems: 1
+                type: array
+              grpc:
+                type: boolean
+              headers:
+                additionalProperties:
+                  type: string
+                type: object
+              health_checks:
+                items:
+                  description: HealthCheck specifies settings for performing active
+                    health checking on upstreams
+                  properties:
+                    health_check:
+                      description: Configuration for where the healthcheck request
+                        should be made to
+                      maxProperties: 1
+                      minProperties: 1
+                      properties:
+                        grpc:
+                          description: HealthCheck for gRPC upstreams. Only one of
+                            grpc_health_check or http_health_check may be specified
+                          properties:
+                            authority:
+                              description: The value of the :authority header in the
+                                gRPC health check request. If left empty the upstream
+                                name will be used.
+                              type: string
+                            upstream_name:
+                              description: The upstream name parameter which will
+                                be sent to gRPC service in the health check message
+                              type: string
+                          required:
+                          - upstream_name
+                          type: object
+                        http:
+                          description: HealthCheck for HTTP upstreams. Only one of
+                            http_health_check or grpc_health_check may be specified
+                          properties:
+                            add_request_headers:
+                              additionalProperties:
+                                properties:
+                                  append:
+                                    type: boolean
+                                  v2Representation:
+                                    enum:
+                                    - ""
+                                    - string
+                                    - "null"
+                                    type: string
+                                  value:
+                                    type: string
+                                type: object
+                              type: object
+                            expected_statuses:
+                              items:
+                                description: A range of response statuses from Start
+                                  to End inclusive
+                                properties:
+                                  max:
+                                    description: End of the statuses to include. Must
+                                      be between 100 and 599 (inclusive)
+                                    maximum: 599
+                                    minimum: 100
+                                    type: integer
+                                  min:
+                                    description: Start of the statuses to include.
+                                      Must be between 100 and 599 (inclusive)
+                                    maximum: 599
+                                    minimum: 100
+                                    type: integer
+                                required:
+                                - max
+                                - min
+                                type: object
+                              type: array
+                            hostname:
+                              type: string
+                            path:
+                              type: string
+                            remove_request_headers:
+                              items:
+                                type: string
+                              type: array
+                          required:
+                          - path
+                          type: object
+                      type: object
+                    healthy_threshold:
+                      description: Number of expected responses for the upstream to
+                        be considered healthy. Defaults to 1.
+                      type: integer
+                    interval:
+                      description: Interval between health checks. Defaults to every
+                        5 seconds.
+                      type: string
+                    timeout:
+                      description: Timeout for connecting to the health checking endpoint.
+                        Defaults to 3 seconds.
+                      type: string
+                    unhealthy_threshold:
+                      description: Number of non-expected responses for the upstream
+                        to be considered unhealthy. A single 503 will mark the upstream
+                        as unhealthy regardless of the threshold. Defaults to 2.
+                      type: integer
+                  required:
+                  - health_check
+                  type: object
+                minItems: 1
+                type: array
+              host:
+                description: "Exact match for the hostname of a request if HostRegex
+                  is false; regex match for the hostname if HostRegex is true. \n
+                  Host specifies both a match for the ':authority' header of a request,
+                  as well as a match criterion for Host CRDs: a Mapping that specifies
+                  Host will not associate with a Host that doesn't have a matching
+                  Hostname. \n If both Host and Hostname are set, an error is logged,
+                  Host is ignored, and Hostname is used. \n DEPRECATED: Host is either
+                  an exact match or a regex, depending on HostRegex. Use HostName
+                  instead."
+                type: string
+              host_redirect:
+                type: boolean
+              host_regex:
+                description: 'DEPRECATED: Host is either an exact match or a regex,
+                  depending on HostRegex. Use HostName instead.'
+                type: boolean
+              host_rewrite:
+                type: string
+              hostname:
+                description: "Hostname is a DNS glob specifying the hosts to which
+                  this Mapping applies. \n Hostname specifies both a match for the
+                  ':authority' header of a request, as well as a match criterion for
+                  Host CRDs: a Mapping that specifies Hostname will not associate
+                  with a Host that doesn't have a matching Hostname. \n If both Host
+                  and Hostname are set, an error is logged, Host is ignored, and Hostname
+                  is used."
+                type: string
+              idle_timeout_ms:
+                type: integer
+              keepalive:
+                properties:
+                  idle_time:
+                    type: integer
+                  interval:
+                    type: integer
+                  probes:
+                    type: integer
+                type: object
+              labels:
+                additionalProperties:
+                  description: A MappingLabelGroupsArray is an array of MappingLabelGroups.
+                    I know, complex.
+                  items:
+                    additionalProperties:
+                      description: 'A MappingLabelsArray is the value in the MappingLabelGroup:
+                        an array of label specifiers.'
+                      items:
+                        description: "A MappingLabelSpecifier (finally!) defines a
+                          single label. \n This mimics envoy/config/route/v3/route_components.proto:RateLimit:Action:action_specifier."
+                        maxProperties: 1
+                        minProperties: 1
+                        properties:
+                          destination_cluster:
+                            description: Sets the label "destination_cluster=«Envoy
+                              destination cluster name»".
+                            properties:
+                              key:
+                                enum:
+                                - destination_cluster
+                                type: string
+                            required:
+                            - key
+                            type: object
+                          generic_key:
+                            description: Sets the label "«key»=«value»" (where by
+                              default «key» is "generic_key").
+                            properties:
+                              key:
+                                description: The default is "generic_key".
+                                type: string
+                              v2Shorthand:
+                                type: boolean
+                              value:
+                                type: string
+                            required:
+                            - value
+                            type: object
+                          remote_address:
+                            description: Sets the label "remote_address=«IP address
+                              of the client»".
+                            properties:
+                              key:
+                                enum:
+                                - remote_address
+                                type: string
+                            required:
+                            - key
+                            type: object
+                          request_headers:
+                            description: If the «header_name» header is set, then
+                              set the label "«key»=«Value of the «header_name» header»";
+                              otherwise skip applying this label group.
+                            properties:
+                              header_name:
+                                type: string
+                              key:
+                                type: string
+                              omit_if_not_present:
+                                type: boolean
+                            required:
+                            - header_name
+                            - key
+                            type: object
+                          source_cluster:
+                            description: Sets the label "source_cluster=«Envoy source
+                              cluster name»".
+                            properties:
+                              key:
+                                enum:
+                                - source_cluster
+                                type: string
+                            required:
+                            - key
+                            type: object
+                        type: object
+                      type: array
+                    description: 'A MappingLabelGroup is a single element of a MappingLabelGroupsArray:
+                      a second map, where the key is a human-readable name that identifies
+                      the group.'
+                    maxProperties: 1
+                    minProperties: 1
+                    type: object
+                  type: array
+                description: A DomainMap is the overall Mapping.spec.Labels type.
+                  It maps domains (kind of like namespaces for Mapping labels) to
+                  arrays of label groups.
+                type: object
+              load_balancer:
+                properties:
+                  cookie:
+                    properties:
+                      name:
+                        type: string
+                      path:
+                        type: string
+                      ttl:
+                        type: string
+                    required:
+                    - name
+                    type: object
+                  header:
+                    type: string
+                  policy:
+                    enum:
+                    - round_robin
+                    - ring_hash
+                    - maglev
+                    - least_request
+                    type: string
+                  source_ip:
+                    type: boolean
+                required:
+                - policy
+                type: object
+              method:
+                type: string
+              method_regex:
+                type: boolean
+              modules:
+                items:
+                  type: object
+                  x-kubernetes-preserve-unknown-fields: true
+                type: array
+              outlier_detection:
+                type: string
+              path_redirect:
+                description: Path replacement to use when generating an HTTP redirect.
+                  Used with `host_redirect`.
+                type: string
+              precedence:
+                type: integer
+              prefix:
+                type: string
+              prefix_exact:
+                type: boolean
+              prefix_redirect:
+                description: Prefix rewrite to use when generating an HTTP redirect.
+                  Used with `host_redirect`.
+                type: string
+              prefix_regex:
+                type: boolean
+              priority:
+                type: string
+              query_parameters:
+                additionalProperties:
+                  type: string
+                type: object
+              redirect_response_code:
+                description: The response code to use when generating an HTTP redirect.
+                  Defaults to 301. Used with `host_redirect`.
+                enum:
+                - 301
+                - 302
+                - 303
+                - 307
+                - 308
+                type: integer
+              regex_headers:
+                additionalProperties:
+                  type: string
+                type: object
+              regex_query_parameters:
+                additionalProperties:
+                  type: string
+                type: object
+              regex_redirect:
+                description: Prefix regex rewrite to use when generating an HTTP redirect.
+                  Used with `host_redirect`.
+                properties:
+                  pattern:
+                    type: string
+                  substitution:
+                    type: string
+                type: object
+              regex_rewrite:
+                properties:
+                  pattern:
+                    type: string
+                  substitution:
+                    type: string
+                type: object
+              remove_request_headers:
+                items:
+                  type: string
+                type: array
+              remove_response_headers:
+                items:
+                  type: string
+                type: array
+              resolver:
+                type: string
+              respect_dns_ttl:
+                type: boolean
+              retry_policy:
+                properties:
+                  num_retries:
+                    type: integer
+                  per_try_timeout:
+                    type: string
+                  retry_on:
+                    enum:
+                    - 5xx
+                    - gateway-error
+                    - connect-failure
+                    - retriable-4xx
+                    - refused-stream
+                    - retriable-status-codes
+                    type: string
+                type: object
+              rewrite:
+                type: string
+              service:
+                type: string
+              shadow:
+                type: boolean
+              stats_name:
+                type: string
+              timeout_ms:
+                description: The timeout for requests that use this Mapping. Overrides
+                  `cluster_request_timeout_ms` set on the Ambassador Module, if it
+                  exists.
+                type: integer
+              tls:
+                type: string
+              use_websocket:
+                description: 'use_websocket is deprecated, and is equivlaent to setting
+                  `allow_upgrade: ["websocket"]`'
+                type: boolean
+              v2BoolHeaders:
+                items:
+                  type: string
+                type: array
+              v2BoolQueryParameters:
+                items:
+                  type: string
+                type: array
+              # TODO: uncomment when [bug](https://github.com/fabric8io/kubernetes-client/issues/5457) is resolved
+              # v2ExplicitTLS:
+              #   description: V2ExplicitTLS controls some vanity/stylistic elements
+              #     when converting from v3alpha1 to v2.  The values in an V2ExplicitTLS
+              #     should not in any way affect the runtime operation of Emissary;
+              #     except that it may affect internal names in the Envoy config, which
+              #     may in turn affect stats names.  But it should not affect any end-user
+              #     observable behavior.
+              #   properties:
+              #     serviceScheme:
+              #       description: "ServiceScheme specifies how to spell and capitalize
+              #         the scheme-part of the service URL. \n Acceptable values are
+              #         \"http://\" (case-insensitive), \"https://\" (case-insensitive),
+              #         or \"\".  The value is used if it agrees with whether or not
+              #         this resource enables TLS origination, or if something else
+              #         in the resource overrides the scheme."
+              #       pattern: ^([hH][tT][tT][pP][sS]?://)?$
+              #       type: string
+              #     tls:
+              #       description: "TLS controls whether and how to represent the \"tls\"
+              #         field when its value could be implied by the \"service\" field.
+              #         \ In v2, there were a lot of different ways to spell an \"empty\"
+              #         value, and this field specifies which way to spell it (and will
+              #         therefore only be used if the value will indeed be empty). \n
+              #         | Value        | Representation                        | Meaning
+              #         of representation          | |--------------+---------------------------------------+------------------------------------|
+              #         | \"\"           | omit the field                        | defer
+              #         to service (no TLSContext)   | | \"null\"       | store an explicit
+              #         \"null\" in the field | defer to service (no TLSContext)   |
+              #         | \"string\"     | store an empty string in the field    | defer
+              #         to service (no TLSContext)   | | \"bool:false\" | store a Boolean
+              #         \"false\" in the field  | defer to service (no TLSContext)   |
+              #         | \"bool:true\"  | store a Boolean \"true\" in the field   |
+              #         originate TLS (no TLSContext)      | \n If the meaning of the
+              #         representation contradicts anything else (if a TLSContext is
+              #         to be used, or in the case of \"bool:true\" if TLS is not to
+              #         be originated), then this field is ignored."
+              #       enum:
+              #       - ""
+              #       - "null"
+              #       - bool:true
+              #       - bool:false
+              #       - string
+              #       type: string
+              #   type: object
+              weight:
+                type: integer
+            required:
+            - prefix
+            - service
+            type: object
+          status:
+            description: MappingStatus defines the observed state of Mapping
+            properties:
+              reason:
+                type: string
+              state:
+                enum:
+                - ""
+                - Inactive
+                - Running
+                type: string
+            type: object
+        type: object
+    served: true
+    storage: false
+    subresources:
+      status: {}
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  annotations:
+    controller-gen.kubebuilder.io/version: v0.12.0
+  labels:
+    app.kubernetes.io/instance: emissary-apiext
+    app.kubernetes.io/managed-by: kubectl_apply_-f_emissary-apiext.yaml
+    app.kubernetes.io/name: emissary-apiext
+    app.kubernetes.io/part-of: emissary-apiext
+  name: tlscontexts.getambassador.io
+spec:
+  conversion:
+    strategy: Webhook
+    webhook:
+      clientConfig:
+        service:
+          name: emissary-apiext
+          namespace: emissary-system
+      conversionReviewVersions:
+      - v1
+  group: getambassador.io
+  names:
+    categories:
+    - ambassador-crds
+    kind: TLSContext
+    listKind: TLSContextList
+    plural: tlscontexts
+    singular: tlscontext
+  preserveUnknownFields: false
+  scope: Namespaced
+  versions:
+  - name: v1
+    schema:
+      openAPIV3Schema:
+        description: TLSContext is the Schema for the tlscontexts API
+        properties:
+          apiVersion:
+            description: 'APIVersion defines the versioned schema of this representation
+              of an object. Servers should convert recognized schemas to the latest
+              internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources'
+            type: string
+          kind:
+            description: 'Kind is a string value representing the REST resource this
+              object represents. Servers may infer this from the endpoint the client
+              submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
+            type: string
+          metadata:
+            type: object
+          spec:
+            description: TLSContextSpec defines the desired state of TLSContext
+            properties:
+              alpn_protocols:
+                type: string
+              ca_secret:
+                type: string
+              cacert_chain_file:
+                type: string
+              cert_chain_file:
+                type: string
+              cert_required:
+                type: boolean
+              cipher_suites:
+                items:
+                  type: string
+                type: array
+              ecdh_curves:
+                items:
+                  type: string
+                type: array
+              hosts:
+                items:
+                  type: string
+                type: array
+              max_tls_version:
+                enum:
+                - v1.0
+                - v1.1
+                - v1.2
+                - v1.3
+                type: string
+              min_tls_version:
+                enum:
+                - v1.0
+                - v1.1
+                - v1.2
+                - v1.3
+                type: string
+              private_key_file:
+                type: string
+              redirect_cleartext_from:
+                type: integer
+              secret:
+                type: string
+              secret_namespacing:
+                type: boolean
+              sni:
+                type: string
+              v3CRLSecret:
+                type: string
+            type: object
+            x-kubernetes-preserve-unknown-fields: true
+        type: object
+    served: true
+    storage: false
+  - name: v2
+    schema:
+      openAPIV3Schema:
+        description: TLSContext is the Schema for the tlscontexts API
+        properties:
+          apiVersion:
+            description: 'APIVersion defines the versioned schema of this representation
+              of an object. Servers should convert recognized schemas to the latest
+              internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources'
+            type: string
+          kind:
+            description: 'Kind is a string value representing the REST resource this
+              object represents. Servers may infer this from the endpoint the client
+              submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
+            type: string
+          metadata:
+            type: object
+          spec:
+            description: TLSContextSpec defines the desired state of TLSContext
+            properties:
+              alpn_protocols:
+                type: string
+              # [operator] added manually by coping over from v3alpha1
+              ambassador_id:
+                description: "AmbassadorID declares which Ambassador instances should
+                  pay attention to this resource. If no value is provided, the default
+                  is: \n ambassador_id: - \"default\""
+                items:
+                  type: string
+                type: array
+              ca_secret:
+                type: string
+              cacert_chain_file:
+                type: string
+              cert_chain_file:
+                type: string
+              cert_required:
+                type: boolean
+              cipher_suites:
+                items:
+                  type: string
+                type: array
+              ecdh_curves:
+                items:
+                  type: string
+                type: array
+              hosts:
+                items:
+                  type: string
+                type: array
+              max_tls_version:
+                enum:
+                - v1.0
+                - v1.1
+                - v1.2
+                - v1.3
+                type: string
+              min_tls_version:
+                enum:
+                - v1.0
+                - v1.1
+                - v1.2
+                - v1.3
+                type: string
+              private_key_file:
+                type: string
+              redirect_cleartext_from:
+                type: integer
+              secret:
+                type: string
+              secret_namespacing:
+                type: boolean
+              sni:
+                type: string
+              v3CRLSecret:
+                type: string
+            type: object
+            x-kubernetes-preserve-unknown-fields: true
+        type: object
+    served: true
+    storage: true
+  - name: v3alpha1
+    schema:
+      openAPIV3Schema:
+        description: TLSContext is the Schema for the tlscontexts API
+        properties:
+          apiVersion:
+            description: 'APIVersion defines the versioned schema of this representation
+              of an object. Servers should convert recognized schemas to the latest
+              internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources'
+            type: string
+          kind:
+            description: 'Kind is a string value representing the REST resource this
+              object represents. Servers may infer this from the endpoint the client
+              submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
+            type: string
+          metadata:
+            type: object
+          spec:
+            description: TLSContextSpec defines the desired state of TLSContext
+            properties:
+              alpn_protocols:
+                type: string
+              ambassador_id:
+                description: "AmbassadorID declares which Ambassador instances should
+                  pay attention to this resource. If no value is provided, the default
+                  is: \n ambassador_id: - \"default\""
+                items:
+                  type: string
+                type: array
+              ca_secret:
+                type: string
+              cacert_chain_file:
+                type: string
+              cert_chain_file:
+                type: string
+              cert_required:
+                type: boolean
+              cipher_suites:
+                items:
+                  type: string
+                type: array
+              crl_secret:
+                type: string
+              ecdh_curves:
+                items:
+                  type: string
+                type: array
+              hosts:
+                items:
+                  type: string
+                type: array
+              max_tls_version:
+                enum:
+                - v1.0
+                - v1.1
+                - v1.2
+                - v1.3
+                type: string
+              min_tls_version:
+                enum:
+                - v1.0
+                - v1.1
+                - v1.2
+                - v1.3
+                type: string
+              private_key_file:
+                type: string
+              redirect_cleartext_from:
+                type: integer
+              secret:
+                type: string
+              secret_namespacing:
+                type: boolean
+              sni:
+                type: string
+            type: object
+        type: object
+    served: true
+    storage: false
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  annotations:
+    controller-gen.kubebuilder.io/version: v0.12.0
+  labels:
+    app.kubernetes.io/instance: emissary-apiext
+    app.kubernetes.io/managed-by: kubectl_apply_-f_emissary-apiext.yaml
+    app.kubernetes.io/name: emissary-apiext
+    app.kubernetes.io/part-of: emissary-apiext
+  name: hosts.getambassador.io
+spec:
+  conversion:
+    strategy: Webhook
+    webhook:
+      clientConfig:
+        service:
+          name: emissary-apiext
+          namespace: emissary-system
+      conversionReviewVersions:
+      - v1
+  group: getambassador.io
+  names:
+    categories:
+    - ambassador-crds
+    kind: Host
+    listKind: HostList
+    plural: hosts
+    singular: host
+  preserveUnknownFields: false
+  scope: Namespaced
+  versions:
+  - additionalPrinterColumns:
+    - jsonPath: .spec.hostname
+      name: Hostname
+      type: string
+    - jsonPath: .status.state
+      name: State
+      type: string
+    - jsonPath: .status.phaseCompleted
+      name: Phase Completed
+      type: string
+    - jsonPath: .status.phasePending
+      name: Phase Pending
+      type: string
+    - jsonPath: .metadata.creationTimestamp
+      name: Age
+      type: date
+    name: v2
+    schema:
+      openAPIV3Schema:
+        description: Host is the Schema for the hosts API
+        properties:
+          apiVersion:
+            description: 'APIVersion defines the versioned schema of this representation
+              of an object. Servers should convert recognized schemas to the latest
+              internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources'
+            type: string
+          kind:
+            description: 'Kind is a string value representing the REST resource this
+              object represents. Servers may infer this from the endpoint the client
+              submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
+            type: string
+          metadata:
+            type: object
+          spec:
+            description: HostSpec defines the desired state of Host
+            properties:
+              acmeProvider:
+                description: Specifies whether/who to talk ACME with to automatically
+                  manage the $tlsSecret.
+                properties:
+                  authority:
+                    description: Specifies who to talk ACME with to get certs. Defaults
+                      to Let's Encrypt; if "none" (case-insensitive), do not try to
+                      do ACME for this Host.
+                    type: string
+                  email:
+                    type: string
+                  privateKeySecret:
+                    description: "Specifies the Kubernetes Secret to use to store
+                      the private key of the ACME account (essentially, where to store
+                      the auto-generated password for the auto-created ACME account).
+                      \ You should not normally need to set this--the default value
+                      is based on a combination of the ACME authority being registered
+                      wit and the email address associated with the account. \n Note
+                      that this is a native-Kubernetes-style core.v1.LocalObjectReference,
+                      not an Ambassador-style `{name}.{namespace}` string.  Because
+                      we're opinionated, it does not support referencing a Secret
+                      in another namespace (because most native Kubernetes resources
+                      don't support that), but if we ever abandon that opinion and
+                      decide to support non-local references it, it would be by adding
+                      a `namespace:` field by changing it from a core.v1.LocalObjectReference
+                      to a core.v1.SecretReference, not by adopting the `{name}.{namespace}`
+                      notation."
+                    properties:
+                      name:
+                        description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
+                          TODO: Add other useful fields. apiVersion, kind, uid?'
+                        type: string
+                    type: object
+                    x-kubernetes-map-type: atomic
+                  registration:
+                    description: This is normally set automatically
+                    type: string
+                type: object
+              ambassador_id:
+                description: Common to all Ambassador objects (and optional).
+                items:
+                  type: string
+                type: array
+              hostname:
+                description: Hostname by which the Ambassador can be reached.
+                type: string
+              previewUrl:
+                description: Configuration for the Preview URL feature of Service
+                  Preview. Defaults to preview URLs not enabled.
+                properties:
+                  enabled:
+                    description: Is the Preview URL feature enabled?
+                    type: boolean
+                  type:
+                    description: What type of Preview URL is allowed?
+                    enum:
+                    - Path
+                    type: string
+                type: object
+              requestPolicy:
+                description: Request policy definition.
+                properties:
+                  insecure:
+                    properties:
+                      action:
+                        enum:
+                        - Redirect
+                        - Reject
+                        - Route
+                        type: string
+                      additionalPort:
+                        type: integer
+                    type: object
+                type: object
+              selector:
+                description: Selector by which we can find further configuration.
+                  Defaults to hostname=$hostname
+                properties:
+                  matchExpressions:
+                    description: matchExpressions is a list of label selector requirements.
+                      The requirements are ANDed.
+                    items:
+                      description: A label selector requirement is a selector that
+                        contains values, a key, and an operator that relates the key
+                        and values.
+                      properties:
+                        key:
+                          description: key is the label key that the selector applies
+                            to.
+                          type: string
+                        operator:
+                          description: operator represents a key's relationship to
+                            a set of values. Valid operators are In, NotIn, Exists
+                            and DoesNotExist.
+                          type: string
+                        values:
+                          description: values is an array of string values. If the
+                            operator is In or NotIn, the values array must be non-empty.
+                            If the operator is Exists or DoesNotExist, the values
+                            array must be empty. This array is replaced during a strategic
+                            merge patch.
+                          items:
+                            type: string
+                          type: array
+                      required:
+                      - key
+                      - operator
+                      type: object
+                    type: array
+                  matchLabels:
+                    additionalProperties:
+                      type: string
+                    description: matchLabels is a map of {key,value} pairs. A single
+                      {key,value} in the matchLabels map is equivalent to an element
+                      of matchExpressions, whose key field is "key", the operator
+                      is "In", and the values array contains only "value". The requirements
+                      are ANDed.
+                    type: object
+                type: object
+                x-kubernetes-map-type: atomic
+              tls:
+                description: TLS configuration.  It is not valid to specify both `tlsContext`
+                  and `tls`.
+                properties:
+                  alpn_protocols:
+                    type: string
+                  ca_secret:
+                    type: string
+                  cacert_chain_file:
+                    type: string
+                  cert_chain_file:
+                    type: string
+                  cert_required:
+                    type: boolean
+                  cipher_suites:
+                    items:
+                      type: string
+                    type: array
+                  ecdh_curves:
+                    items:
+                      type: string
+                    type: array
+                  max_tls_version:
+                    type: string
+                  min_tls_version:
+                    type: string
+                  private_key_file:
+                    type: string
+                  redirect_cleartext_from:
+                    type: integer
+                  sni:
+                    type: string
+                  v3CRLSecret:
+                    type: string
+                type: object
+              tlsContext:
+                description: "Name of the TLSContext the Host resource is linked with.
+                  It is not valid to specify both `tlsContext` and `tls`. \n Note
+                  that this is a native-Kubernetes-style core.v1.LocalObjectReference,
+                  not an Ambassador-style `{name}.{namespace}` string.  Because we're
+                  opinionated, it does not support referencing a Secret in another
+                  namespace (because most native Kubernetes resources don't support
+                  that), but if we ever abandon that opinion and decide to support
+                  non-local references it, it would be by adding a `namespace:` field
+                  by changing it from a core.v1.LocalObjectReference to a core.v1.SecretReference,
+                  not by adopting the `{name}.{namespace}` notation."
+                properties:
+                  name:
+                    description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
+                      TODO: Add other useful fields. apiVersion, kind, uid?'
+                    type: string
+                type: object
+                x-kubernetes-map-type: atomic
+              tlsSecret:
+                description: Name of the Kubernetes secret into which to save generated
+                  certificates.  If ACME is enabled (see $acmeProvider), then the
+                  default is $hostname; otherwise the default is "".  If the value
+                  is "", then we do not do TLS for this Host.
+                properties:
+                  name:
+                    description: name is unique within a namespace to reference a
+                      secret resource.
+                    type: string
+                  namespace:
+                    description: namespace defines the space within which the secret
+                      name must be unique.
+                    type: string
+                type: object
+                x-kubernetes-map-type: atomic
+            type: object
+            x-kubernetes-preserve-unknown-fields: true
+          status:
+            description: HostStatus defines the observed state of Host
+            properties:
+              errorBackoff:
+                type: string
+              errorReason:
+                description: errorReason, errorTimestamp, and errorBackoff are valid
+                  when state==Error.
+                type: string
+              errorTimestamp:
+                format: date-time
+                type: string
+              phaseCompleted:
+                description: phaseCompleted and phasePending are valid when state==Pending
+                  or state==Error.
+                enum:
+                - NA
+                - DefaultsFilled
+                - ACMEUserPrivateKeyCreated
+                - ACMEUserRegistered
+                - ACMECertificateChallenge
+                type: string
+              phasePending:
+                description: phaseCompleted and phasePending are valid when state==Pending
+                  or state==Error.
+                enum:
+                - NA
+                - DefaultsFilled
+                - ACMEUserPrivateKeyCreated
+                - ACMEUserRegistered
+                - ACMECertificateChallenge
+                type: string
+              state:
+                enum:
+                - Initial
+                - Pending
+                - Ready
+                - Error
+                type: string
+              tlsCertificateSource:
+                enum:
+                - Unknown
+                - None
+                - Other
+                - ACME
+                type: string
+            type: object
+        type: object
+    served: true
+    storage: true
+    subresources:
+      status: {}
+  - additionalPrinterColumns:
+    - jsonPath: .spec.hostname
+      name: Hostname
+      type: string
+    - jsonPath: .status.state
+      name: State
+      type: string
+    - jsonPath: .status.phaseCompleted
+      name: Phase Completed
+      type: string
+    - jsonPath: .status.phasePending
+      name: Phase Pending
+      type: string
+    - jsonPath: .metadata.creationTimestamp
+      name: Age
+      type: date
+    name: v3alpha1
+    schema:
+      openAPIV3Schema:
+        description: Host is the Schema for the hosts API
+        properties:
+          apiVersion:
+            description: 'APIVersion defines the versioned schema of this representation
+              of an object. Servers should convert recognized schemas to the latest
+              internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources'
+            type: string
+          kind:
+            description: 'Kind is a string value representing the REST resource this
+              object represents. Servers may infer this from the endpoint the client
+              submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
+            type: string
+          metadata:
+            type: object
+          spec:
+            description: HostSpec defines the desired state of Host
+            properties:
+              acmeProvider:
+                description: Specifies whether/who to talk ACME with to automatically
+                  manage the $tlsSecret.
+                properties:
+                  authority:
+                    description: Specifies who to talk ACME with to get certs. Defaults
+                      to Let's Encrypt; if "none" (case-insensitive), do not try to
+                      do ACME for this Host.
+                    type: string
+                  email:
+                    type: string
+                  privateKeySecret:
+                    description: "Specifies the Kubernetes Secret to use to store
+                      the private key of the ACME account (essentially, where to store
+                      the auto-generated password for the auto-created ACME account).
+                      \ You should not normally need to set this--the default value
+                      is based on a combination of the ACME authority being registered
+                      wit and the email address associated with the account. \n Note
+                      that this is a native-Kubernetes-style core.v1.LocalObjectReference,
+                      not an Ambassador-style `{name}.{namespace}` string.  Because
+                      we're opinionated, it does not support referencing a Secret
+                      in another namespace (because most native Kubernetes resources
+                      don't support that), but if we ever abandon that opinion and
+                      decide to support non-local references it, it would be by adding
+                      a `namespace:` field by changing it from a core.v1.LocalObjectReference
+                      to a core.v1.SecretReference, not by adopting the `{name}.{namespace}`
+                      notation."
+                    properties:
+                      name:
+                        description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
+                          TODO: Add other useful fields. apiVersion, kind, uid?'
+                        type: string
+                    type: object
+                    x-kubernetes-map-type: atomic
+                  registration:
+                    description: This is normally set automatically
+                    type: string
+                type: object
+              ambassador_id:
+                description: Common to all Ambassador objects (and optional).
+                items:
+                  type: string
+                type: array
+              hostname:
+                description: Hostname by which the Ambassador can be reached.
+                type: string
+              mappingSelector:
+                description: Selector for Mappings we'll associate with this Host.
+                  At the moment, Selector and MappingSelector are synonyms, but that
+                  will change soon.
+                properties:
+                  matchExpressions:
+                    description: matchExpressions is a list of label selector requirements.
+                      The requirements are ANDed.
+                    items:
+                      description: A label selector requirement is a selector that
+                        contains values, a key, and an operator that relates the key
+                        and values.
+                      properties:
+                        key:
+                          description: key is the label key that the selector applies
+                            to.
+                          type: string
+                        operator:
+                          description: operator represents a key's relationship to
+                            a set of values. Valid operators are In, NotIn, Exists
+                            and DoesNotExist.
+                          type: string
+                        values:
+                          description: values is an array of string values. If the
+                            operator is In or NotIn, the values array must be non-empty.
+                            If the operator is Exists or DoesNotExist, the values
+                            array must be empty. This array is replaced during a strategic
+                            merge patch.
+                          items:
+                            type: string
+                          type: array
+                      required:
+                      - key
+                      - operator
+                      type: object
+                    type: array
+                  matchLabels:
+                    additionalProperties:
+                      type: string
+                    description: matchLabels is a map of {key,value} pairs. A single
+                      {key,value} in the matchLabels map is equivalent to an element
+                      of matchExpressions, whose key field is "key", the operator
+                      is "In", and the values array contains only "value". The requirements
+                      are ANDed.
+                    type: object
+                type: object
+                x-kubernetes-map-type: atomic
+              previewUrl:
+                description: Configuration for the Preview URL feature of Service
+                  Preview. Defaults to preview URLs not enabled.
+                properties:
+                  enabled:
+                    description: Is the Preview URL feature enabled?
+                    type: boolean
+                  type:
+                    description: What type of Preview URL is allowed?
+                    enum:
+                    - Path
+                    type: string
+                type: object
+              requestPolicy:
+                description: Request policy definition.
+                properties:
+                  insecure:
+                    properties:
+                      action:
+                        enum:
+                        - Redirect
+                        - Reject
+                        - Route
+                        type: string
+                      additionalPort:
+                        type: integer
+                    type: object
+                type: object
+              selector:
+                description: 'DEPRECATED: Selector by which we can find further configuration.
+                  Use MappingSelector instead.'
+                properties:
+                  matchExpressions:
+                    description: matchExpressions is a list of label selector requirements.
+                      The requirements are ANDed.
+                    items:
+                      description: A label selector requirement is a selector that
+                        contains values, a key, and an operator that relates the key
+                        and values.
+                      properties:
+                        key:
+                          description: key is the label key that the selector applies
+                            to.
+                          type: string
+                        operator:
+                          description: operator represents a key's relationship to
+                            a set of values. Valid operators are In, NotIn, Exists
+                            and DoesNotExist.
+                          type: string
+                        values:
+                          description: values is an array of string values. If the
+                            operator is In or NotIn, the values array must be non-empty.
+                            If the operator is Exists or DoesNotExist, the values
+                            array must be empty. This array is replaced during a strategic
+                            merge patch.
+                          items:
+                            type: string
+                          type: array
+                      required:
+                      - key
+                      - operator
+                      type: object
+                    type: array
+                  matchLabels:
+                    additionalProperties:
+                      type: string
+                    description: matchLabels is a map of {key,value} pairs. A single
+                      {key,value} in the matchLabels map is equivalent to an element
+                      of matchExpressions, whose key field is "key", the operator
+                      is "In", and the values array contains only "value". The requirements
+                      are ANDed.
+                    type: object
+                type: object
+                x-kubernetes-map-type: atomic
+              tls:
+                description: TLS configuration.  It is not valid to specify both `tlsContext`
+                  and `tls`.
+                properties:
+                  alpn_protocols:
+                    type: string
+                  ca_secret:
+                    type: string
+                  cacert_chain_file:
+                    type: string
+                  cert_chain_file:
+                    type: string
+                  cert_required:
+                    type: boolean
+                  cipher_suites:
+                    items:
+                      type: string
+                    type: array
+                  crl_secret:
+                    type: string
+                  ecdh_curves:
+                    items:
+                      type: string
+                    type: array
+                  max_tls_version:
+                    type: string
+                  min_tls_version:
+                    type: string
+                  private_key_file:
+                    type: string
+                  redirect_cleartext_from:
+                    type: integer
+                  sni:
+                    type: string
+                type: object
+              tlsContext:
+                description: "Name of the TLSContext the Host resource is linked with.
+                  It is not valid to specify both `tlsContext` and `tls`. \n Note
+                  that this is a native-Kubernetes-style core.v1.LocalObjectReference,
+                  not an Ambassador-style `{name}.{namespace}` string.  Because we're
+                  opinionated, it does not support referencing a Secret in another
+                  namespace (because most native Kubernetes resources don't support
+                  that), but if we ever abandon that opinion and decide to support
+                  non-local references it, it would be by adding a `namespace:` field
+                  by changing it from a core.v1.LocalObjectReference to a core.v1.SecretReference,
+                  not by adopting the `{name}.{namespace}` notation."
+                properties:
+                  name:
+                    description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
+                      TODO: Add other useful fields. apiVersion, kind, uid?'
+                    type: string
+                type: object
+                x-kubernetes-map-type: atomic
+              tlsSecret:
+                description: Name of the Kubernetes secret into which to save generated
+                  certificates.  If ACME is enabled (see $acmeProvider), then the
+                  default is $hostname; otherwise the default is "".  If the value
+                  is "", then we do not do TLS for this Host.
+                properties:
+                  name:
+                    description: name is unique within a namespace to reference a
+                      secret resource.
+                    type: string
+                  namespace:
+                    description: namespace defines the space within which the secret
+                      name must be unique.
+                    type: string
+                type: object
+                x-kubernetes-map-type: atomic
+            type: object
+          status:
+            description: HostStatus defines the observed state of Host
+            properties:
+              errorBackoff:
+                type: string
+              errorReason:
+                description: errorReason, errorTimestamp, and errorBackoff are valid
+                  when state==Error.
+                type: string
+              errorTimestamp:
+                format: date-time
+                type: string
+              phaseCompleted:
+                description: phaseCompleted and phasePending are valid when state==Pending
+                  or state==Error.
+                enum:
+                - NA
+                - DefaultsFilled
+                - ACMEUserPrivateKeyCreated
+                - ACMEUserRegistered
+                - ACMECertificateChallenge
+                type: string
+              phasePending:
+                description: phaseCompleted and phasePending are valid when state==Pending
+                  or state==Error.
+                enum:
+                - NA
+                - DefaultsFilled
+                - ACMEUserPrivateKeyCreated
+                - ACMEUserRegistered
+                - ACMECertificateChallenge
+                type: string
+              state:
+                enum:
+                - Initial
+                - Pending
+                - Ready
+                - Error
+                type: string
+              tlsCertificateSource:
+                enum:
+                - Unknown
+                - None
+                - Other
+                - ACME
+                type: string
+            type: object
+        type: object
+    served: true
+    storage: false
+    subresources:
+      status: {}
diff --git a/charts/k8s-gerrit/operator/src/main/resources/log4j2.xml b/charts/k8s-gerrit/operator/src/main/resources/log4j2.xml
new file mode 100644
index 0000000..f3dd273
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/main/resources/log4j2.xml
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<Configuration status="INFO">
+    <Appenders>
+        <Console name="Console" target="SYSTEM_OUT">
+            <PatternLayout pattern="%d{HH:mm:ss.SSS} [%-5level] %c:%L [PID:%pid] - %msg%n"/>
+        </Console>
+    </Appenders>
+    <Loggers>
+        <Root level="info">
+            <AppenderRef ref="Console"/>
+        </Root>
+    </Loggers>
+</Configuration>
diff --git a/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/cluster/GerritClusterE2E.java b/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/cluster/GerritClusterE2E.java
new file mode 100644
index 0000000..b73f463
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/cluster/GerritClusterE2E.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.cluster;
+
+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.dependent.NfsIdmapdConfigMap;
+import com.google.gerrit.k8s.operator.cluster.dependent.SharedPVC;
+import com.google.gerrit.k8s.operator.network.IngressType;
+import com.google.gerrit.k8s.operator.test.AbstractGerritOperatorE2ETest;
+import io.fabric8.kubernetes.api.model.ConfigMap;
+import io.fabric8.kubernetes.api.model.PersistentVolumeClaim;
+import org.junit.jupiter.api.Test;
+
+public class GerritClusterE2E extends AbstractGerritOperatorE2ETest {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  @Test
+  void testSharedPvcCreated() {
+    logger.atInfo().log("Waiting max 1 minutes for the shared pvc to be created.");
+    await()
+        .atMost(1, MINUTES)
+        .untilAsserted(
+            () -> {
+              PersistentVolumeClaim pvc =
+                  client
+                      .persistentVolumeClaims()
+                      .inNamespace(operator.getNamespace())
+                      .withName(SharedPVC.SHARED_PVC_NAME)
+                      .get();
+              assertThat(pvc, is(notNullValue()));
+            });
+  }
+
+  @Test
+  void testNfsIdmapdConfigMapCreated() {
+    gerritCluster.setNfsEnabled(true);
+    logger.atInfo().log("Waiting max 1 minutes for the nfs idmapd configmap to be created.");
+    await()
+        .atMost(1, MINUTES)
+        .untilAsserted(
+            () -> {
+              ConfigMap cm =
+                  client
+                      .configMaps()
+                      .inNamespace(operator.getNamespace())
+                      .withName(NfsIdmapdConfigMap.NFS_IDMAPD_CM_NAME)
+                      .get();
+              assertThat(cm, is(notNullValue()));
+            });
+  }
+
+  @Override
+  protected IngressType getIngressType() {
+    return IngressType.INGRESS;
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/cluster/GerritRepositoryConfigTest.java b/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/cluster/GerritRepositoryConfigTest.java
new file mode 100644
index 0000000..5474780
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/cluster/GerritRepositoryConfigTest.java
@@ -0,0 +1,46 @@
+// 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 static org.hamcrest.CoreMatchers.equalTo;
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.MatcherAssert.assertThat;
+
+import com.google.gerrit.k8s.operator.v1alpha.api.model.shared.GerritRepositoryConfig;
+import org.junit.jupiter.api.Test;
+
+public class GerritRepositoryConfigTest {
+
+  @Test
+  public void testFullImageNameComputesCorrectly() {
+    assertThat(
+        new GerritRepositoryConfig().getFullImageName("gerrit"),
+        is(equalTo("docker.io/k8sgerrit/gerrit:latest")));
+
+    GerritRepositoryConfig repoConfig1 = new GerritRepositoryConfig();
+    repoConfig1.setOrg("testorg");
+    repoConfig1.setRegistry("registry.example.com");
+    repoConfig1.setTag("v1.0");
+    assertThat(
+        repoConfig1.getFullImageName("gerrit"),
+        is(equalTo("registry.example.com/testorg/gerrit:v1.0")));
+
+    GerritRepositoryConfig repoConfig2 = new GerritRepositoryConfig();
+    repoConfig2.setOrg(null);
+    repoConfig2.setRegistry(null);
+    repoConfig2.setTag(null);
+    assertThat(repoConfig2.getFullImageName("gerrit"), is(equalTo("gerrit")));
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/gerrit/ClusterManagedGerritWithIngressE2E.java b/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/gerrit/ClusterManagedGerritWithIngressE2E.java
new file mode 100644
index 0000000..c848aa9
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/gerrit/ClusterManagedGerritWithIngressE2E.java
@@ -0,0 +1,140 @@
+// Copyright (C) 2023 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.network.ingress.dependent.GerritClusterIngress.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.hasSize;
+import static org.hamcrest.Matchers.not;
+import static org.hamcrest.Matchers.notNullValue;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+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.network.IngressType;
+import com.google.gerrit.k8s.operator.test.AbstractGerritOperatorE2ETest;
+import com.google.gerrit.k8s.operator.test.TestGerrit;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.gerrit.GerritTemplate;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.gerrit.GerritTemplateSpec.GerritMode;
+import io.fabric8.kubernetes.api.model.networking.v1.Ingress;
+import io.fabric8.kubernetes.api.model.networking.v1.IngressLoadBalancerIngress;
+import io.fabric8.kubernetes.api.model.networking.v1.IngressStatus;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+
+public class ClusterManagedGerritWithIngressE2E extends AbstractGerritOperatorE2ETest {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  @Test
+  void testPrimaryGerritIsCreated() throws Exception {
+    TestGerrit gerrit =
+        new TestGerrit(client, testProps, GerritMode.PRIMARY, "gerrit", operator.getNamespace());
+    GerritTemplate gerritTemplate = gerrit.createGerritTemplate();
+    gerritCluster.addGerrit(gerritTemplate);
+    gerritCluster.deploy();
+
+    logger.atInfo().log("Waiting max 2 minutes for the Ingress to have an external IP.");
+    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<IngressLoadBalancerIngress> lbIngresses = status.getLoadBalancer().getIngress();
+              assertThat(lbIngresses, hasSize(1));
+              assertThat(lbIngresses.get(0).getIp(), is(notNullValue()));
+            });
+
+    GerritApi gerritApi = gerritCluster.getGerritApiClient(gerritTemplate, IngressType.INGRESS);
+    await()
+        .atMost(2, MINUTES)
+        .untilAsserted(
+            () -> {
+              assertDoesNotThrow(() -> gerritApi.config().server().getVersion());
+              assertThat(gerritApi.config().server().getVersion(), notNullValue());
+              assertThat(gerritApi.config().server().getVersion(), not(is("<2.8")));
+              logger.atInfo().log("Gerrit version: %s", gerritApi.config().server().getVersion());
+            });
+  }
+
+  @Test
+  void testGerritReplicaIsCreated() throws Exception {
+    String gerritName = "gerrit-replica";
+    TestGerrit gerrit =
+        new TestGerrit(client, testProps, GerritMode.REPLICA, gerritName, operator.getNamespace());
+    gerritCluster.addGerrit(gerrit.createGerritTemplate());
+    gerritCluster.deploy();
+
+    assertTrue(
+        client
+            .pods()
+            .inNamespace(operator.getNamespace())
+            .withName(gerritName + "-0")
+            .inContainer("gerrit")
+            .getLog()
+            .contains("Gerrit Code Review [replica]"));
+  }
+
+  @Test
+  void testGerritReplicaAndPrimaryGerritAreCreated() throws Exception {
+    String primaryGerritName = "gerrit";
+    TestGerrit primaryGerrit =
+        new TestGerrit(
+            client, testProps, GerritMode.PRIMARY, primaryGerritName, operator.getNamespace());
+    gerritCluster.addGerrit(primaryGerrit.createGerritTemplate());
+    String gerritReplicaName = "gerrit-replica";
+    TestGerrit gerritReplica =
+        new TestGerrit(
+            client, testProps, GerritMode.REPLICA, gerritReplicaName, operator.getNamespace());
+    gerritCluster.addGerrit(gerritReplica.createGerritTemplate());
+    gerritCluster.deploy();
+
+    assertTrue(
+        client
+            .pods()
+            .inNamespace(operator.getNamespace())
+            .withName(primaryGerritName + "-0")
+            .inContainer("gerrit")
+            .getLog()
+            .contains("Gerrit Code Review"));
+
+    assertTrue(
+        client
+            .pods()
+            .inNamespace(operator.getNamespace())
+            .withName(gerritReplicaName + "-0")
+            .inContainer("gerrit")
+            .getLog()
+            .contains("Gerrit Code Review [replica]"));
+  }
+
+  @Override
+  protected IngressType getIngressType() {
+    return IngressType.INGRESS;
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/gerrit/ClusterManagedGerritWithIstioE2E.java b/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/gerrit/ClusterManagedGerritWithIstioE2E.java
new file mode 100644
index 0000000..fdf5ccb
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/gerrit/ClusterManagedGerritWithIstioE2E.java
@@ -0,0 +1,144 @@
+// Copyright (C) 2023 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 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.not;
+import static org.hamcrest.Matchers.notNullValue;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+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.network.IngressType;
+import com.google.gerrit.k8s.operator.test.AbstractGerritOperatorE2ETest;
+import com.google.gerrit.k8s.operator.test.TestGerrit;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.gerrit.GerritTemplate;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.gerrit.GerritTemplateSpec.GerritMode;
+import org.junit.jupiter.api.Test;
+
+public class ClusterManagedGerritWithIstioE2E extends AbstractGerritOperatorE2ETest {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  @Test
+  void testPrimaryGerritWithIstio() throws Exception {
+    GerritTemplate gerrit =
+        new TestGerrit(client, testProps, GerritMode.PRIMARY, "gerrit", operator.getNamespace())
+            .createGerritTemplate();
+    gerritCluster.addGerrit(gerrit);
+    gerritCluster.deploy();
+
+    GerritApi gerritApi = gerritCluster.getGerritApiClient(gerrit, IngressType.ISTIO);
+    await()
+        .atMost(2, MINUTES)
+        .untilAsserted(
+            () -> {
+              assertDoesNotThrow(() -> gerritApi.config().server().getVersion());
+              assertThat(gerritApi.config().server().getVersion(), notNullValue());
+              assertThat(gerritApi.config().server().getVersion(), not(is("<2.8")));
+              logger.atInfo().log("Gerrit version: %s", gerritApi.config().server().getVersion());
+            });
+  }
+
+  @Test
+  void testGerritReplicaIsCreated() throws Exception {
+    String gerritName = "gerrit-replica";
+    TestGerrit gerrit =
+        new TestGerrit(client, testProps, GerritMode.REPLICA, gerritName, operator.getNamespace());
+    gerritCluster.addGerrit(gerrit.createGerritTemplate());
+    gerritCluster.deploy();
+
+    assertTrue(
+        client
+            .pods()
+            .inNamespace(operator.getNamespace())
+            .withName(gerritName + "-0")
+            .inContainer("gerrit")
+            .getLog()
+            .contains("Gerrit Code Review [replica]"));
+  }
+
+  @Test
+  void testMultipleGerritReplicaAreCreated() throws Exception {
+    String gerritName = "gerrit-replica-1";
+    TestGerrit gerrit =
+        new TestGerrit(client, testProps, GerritMode.REPLICA, gerritName, operator.getNamespace());
+    gerritCluster.addGerrit(gerrit.createGerritTemplate());
+    String gerritName2 = "gerrit-replica-2";
+    TestGerrit gerrit2 =
+        new TestGerrit(client, testProps, GerritMode.REPLICA, gerritName2, operator.getNamespace());
+    gerritCluster.addGerrit(gerrit2.createGerritTemplate());
+    gerritCluster.deploy();
+
+    assertTrue(
+        client
+            .pods()
+            .inNamespace(operator.getNamespace())
+            .withName(gerritName + "-0")
+            .inContainer("gerrit")
+            .getLog()
+            .contains("Gerrit Code Review [replica]"));
+
+    assertTrue(
+        client
+            .pods()
+            .inNamespace(operator.getNamespace())
+            .withName(gerritName2 + "-0")
+            .inContainer("gerrit")
+            .getLog()
+            .contains("Gerrit Code Review [replica]"));
+  }
+
+  @Test
+  void testGerritReplicaAndPrimaryGerritAreCreated() throws Exception {
+    String primaryGerritName = "gerrit";
+    TestGerrit primaryGerrit =
+        new TestGerrit(
+            client, testProps, GerritMode.PRIMARY, primaryGerritName, operator.getNamespace());
+    gerritCluster.addGerrit(primaryGerrit.createGerritTemplate());
+    String gerritReplicaName = "gerrit-replica";
+    TestGerrit gerritReplica =
+        new TestGerrit(
+            client, testProps, GerritMode.REPLICA, gerritReplicaName, operator.getNamespace());
+    gerritCluster.addGerrit(gerritReplica.createGerritTemplate());
+    gerritCluster.deploy();
+
+    assertTrue(
+        client
+            .pods()
+            .inNamespace(operator.getNamespace())
+            .withName(primaryGerritName + "-0")
+            .inContainer("gerrit")
+            .getLog()
+            .contains("Gerrit Code Review"));
+
+    assertTrue(
+        client
+            .pods()
+            .inNamespace(operator.getNamespace())
+            .withName(gerritReplicaName + "-0")
+            .inContainer("gerrit")
+            .getLog()
+            .contains("Gerrit Code Review [replica]"));
+  }
+
+  @Override
+  protected IngressType getIngressType() {
+    return IngressType.ISTIO;
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/gerrit/GerritConfigReconciliationE2E.java b/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/gerrit/GerritConfigReconciliationE2E.java
new file mode 100644
index 0000000..342d524
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/gerrit/GerritConfigReconciliationE2E.java
@@ -0,0 +1,163 @@
+// 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 java.util.concurrent.TimeUnit.MINUTES;
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static org.awaitility.Awaitility.await;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.google.gerrit.k8s.operator.network.IngressType;
+import com.google.gerrit.k8s.operator.test.AbstractGerritOperatorE2ETest;
+import com.google.gerrit.k8s.operator.test.TestGerrit;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.gerrit.GerritTemplate;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.gerrit.GerritTemplateSpec;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.gerrit.GerritTemplateSpec.GerritMode;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.shared.HttpSshServiceConfig;
+import java.util.HashMap;
+import java.util.Map;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+public class GerritConfigReconciliationE2E extends AbstractGerritOperatorE2ETest {
+  private static final String GERRIT_NAME = "gerrit";
+  private static final String RESTART_ANNOTATION = "kubectl.kubernetes.io/restartedAt";
+
+  private GerritTemplate gerritTemplate;
+
+  @BeforeEach
+  public void setupGerrit() throws Exception {
+    TestGerrit gerrit =
+        new TestGerrit(client, testProps, GerritMode.PRIMARY, GERRIT_NAME, operator.getNamespace());
+    gerritTemplate = gerrit.createGerritTemplate();
+    gerritCluster.addGerrit(gerritTemplate);
+    gerritCluster.deploy();
+  }
+
+  @Test
+  void testNoRestartIfGerritConfigUnchanged() throws Exception {
+    Map<String, String> annotations = getReplicaSetAnnotations();
+    assertFalse(annotations.containsKey(RESTART_ANNOTATION));
+
+    gerritCluster.removeGerrit(gerritTemplate);
+    GerritTemplateSpec gerritSpec = gerritTemplate.getSpec();
+    HttpSshServiceConfig gerritServiceConfig = new HttpSshServiceConfig();
+    gerritServiceConfig.setHttpPort(48080);
+    gerritSpec.setService(gerritServiceConfig);
+    gerritTemplate.setSpec(gerritSpec);
+    gerritCluster.addGerrit(gerritTemplate);
+    gerritCluster.deploy();
+
+    await()
+        .atMost(30, SECONDS)
+        .untilAsserted(
+            () -> {
+              assertTrue(
+                  client
+                      .services()
+                      .inNamespace(operator.getNamespace())
+                      .withName(GERRIT_NAME)
+                      .get()
+                      .getSpec()
+                      .getPorts()
+                      .stream()
+                      .allMatch(p -> p.getPort() == 48080));
+              assertFalse(getReplicaSetAnnotations().containsKey(RESTART_ANNOTATION));
+            });
+  }
+
+  @Test
+  void testRestartOnGerritConfigMapChange() throws Exception {
+    String podV1Uid =
+        client
+            .pods()
+            .inNamespace(operator.getNamespace())
+            .withName(GERRIT_NAME + "-0")
+            .get()
+            .getMetadata()
+            .getUid();
+
+    gerritCluster.removeGerrit(gerritTemplate);
+    GerritTemplateSpec gerritSpec = gerritTemplate.getSpec();
+    Map<String, String> cfgs = new HashMap<>();
+    cfgs.putAll(gerritSpec.getConfigFiles());
+    cfgs.put("test.config", "[test]\n  test");
+    gerritSpec.setConfigFiles(cfgs);
+    gerritTemplate.setSpec(gerritSpec);
+    gerritCluster.addGerrit(gerritTemplate);
+    gerritCluster.deploy();
+
+    assertGerritRestart(podV1Uid);
+  }
+
+  @Test
+  void testRestartOnGerritSecretChange() throws Exception {
+    String podV1Uid =
+        client
+            .pods()
+            .inNamespace(operator.getNamespace())
+            .withName(GERRIT_NAME + "-0")
+            .get()
+            .getMetadata()
+            .getUid();
+
+    secureConfig.modify("test", "test", "test");
+
+    assertGerritRestart(podV1Uid);
+  }
+
+  private void assertGerritRestart(String uidOld) {
+    await()
+        .atMost(2, MINUTES)
+        .untilAsserted(
+            () -> {
+              assertTrue(
+                  client
+                      .pods()
+                      .inNamespace(operator.getNamespace())
+                      .withName(GERRIT_NAME + "-0")
+                      .isReady());
+              assertTrue(getReplicaSetAnnotations().containsKey(RESTART_ANNOTATION));
+              assertFalse(
+                  uidOld.equals(
+                      client
+                          .pods()
+                          .inNamespace(operator.getNamespace())
+                          .withName(GERRIT_NAME + "-0")
+                          .get()
+                          .getMetadata()
+                          .getUid()));
+            });
+  }
+
+  private Map<String, String> getReplicaSetAnnotations() {
+    return client
+        .apps()
+        .statefulSets()
+        .inNamespace(operator.getNamespace())
+        .withName(GERRIT_NAME)
+        .get()
+        .getSpec()
+        .getTemplate()
+        .getMetadata()
+        .getAnnotations();
+  }
+
+  @Override
+  protected IngressType getIngressType() {
+    return IngressType.INGRESS;
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/gerrit/StandaloneGerritE2E.java b/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/gerrit/StandaloneGerritE2E.java
new file mode 100644
index 0000000..b7359ab
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/gerrit/StandaloneGerritE2E.java
@@ -0,0 +1,64 @@
+// 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 org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.google.gerrit.k8s.operator.network.IngressType;
+import com.google.gerrit.k8s.operator.test.AbstractGerritOperatorE2ETest;
+import com.google.gerrit.k8s.operator.test.TestGerrit;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.gerrit.GerritTemplateSpec.GerritMode;
+import org.junit.jupiter.api.Test;
+
+public class StandaloneGerritE2E extends AbstractGerritOperatorE2ETest {
+
+  @Test
+  void testPrimaryGerritIsCreated() throws Exception {
+    String gerritName = "gerrit";
+    TestGerrit testGerrit = new TestGerrit(client, testProps, gerritName, operator.getNamespace());
+    testGerrit.deploy();
+
+    assertTrue(
+        client
+            .pods()
+            .inNamespace(operator.getNamespace())
+            .withName(gerritName + "-0")
+            .inContainer("gerrit")
+            .getLog()
+            .contains("Gerrit Code Review"));
+  }
+
+  @Test
+  void testGerritReplicaIsCreated() throws Exception {
+    String gerritName = "gerrit-replica";
+    TestGerrit testGerrit =
+        new TestGerrit(client, testProps, GerritMode.REPLICA, gerritName, operator.getNamespace());
+    testGerrit.deploy();
+
+    assertTrue(
+        client
+            .pods()
+            .inNamespace(operator.getNamespace())
+            .withName(gerritName + "-0")
+            .inContainer("gerrit")
+            .getLog()
+            .contains("Gerrit Code Review [replica]"));
+  }
+
+  @Override
+  protected IngressType getIngressType() {
+    return IngressType.INGRESS;
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/gerrit/config/GerritConfigBuilderTest.java b/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/gerrit/config/GerritConfigBuilderTest.java
new file mode 100644
index 0000000..cedcebb
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/gerrit/config/GerritConfigBuilderTest.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.gerrit.config;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.google.gerrit.k8s.operator.v1alpha.api.model.gerrit.Gerrit;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.gerrit.GerritSpec;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.shared.IngressConfig;
+import com.google.gerrit.k8s.operator.v1alpha.gerrit.config.GerritConfigBuilder;
+import java.util.Map;
+import java.util.Set;
+import org.assertj.core.util.Arrays;
+import org.eclipse.jgit.lib.Config;
+import org.junit.jupiter.api.Test;
+
+public class GerritConfigBuilderTest {
+
+  @Test
+  public void emptyGerritConfigContainsAllPresetConfiguration() {
+    Gerrit gerrit = createGerrit("");
+    ConfigBuilder cfgBuilder = new GerritConfigBuilder(gerrit);
+    Config cfg = cfgBuilder.build();
+    for (RequiredOption<?> opt : cfgBuilder.getRequiredOptions()) {
+      if (opt.getExpected() instanceof String || opt.getExpected() instanceof Boolean) {
+        assertTrue(
+            cfg.getString(opt.getSection(), opt.getSubSection(), opt.getKey())
+                .equals(opt.getExpected().toString()));
+      } else if (opt.getExpected() instanceof Set) {
+        assertTrue(
+            Arrays.asList(cfg.getStringList(opt.getSection(), opt.getSubSection(), opt.getKey()))
+                .containsAll((Set<?>) opt.getExpected()));
+      }
+    }
+  }
+
+  @Test
+  public void invalidConfigValueIsRejected() {
+    Gerrit gerrit = createGerrit("[gerrit]\n  basePath = invalid");
+    assertThrows(IllegalStateException.class, () -> new GerritConfigBuilder(gerrit).build());
+  }
+
+  @Test
+  public void validConfigValueIsAccepted() {
+    Gerrit gerrit = createGerrit("[gerrit]\n  basePath = git");
+    assertDoesNotThrow(() -> new GerritConfigBuilder(gerrit).build());
+  }
+
+  @Test
+  public void canonicalWebUrlIsConfigured() {
+    IngressConfig ingressConfig = new IngressConfig();
+    ingressConfig.setEnabled(true);
+    ingressConfig.setHost("gerrit.example.com");
+
+    GerritSpec gerritSpec = new GerritSpec();
+    gerritSpec.setIngress(ingressConfig);
+    Gerrit gerrit = new Gerrit();
+    gerrit.setSpec(gerritSpec);
+    Config cfg = new GerritConfigBuilder(gerrit).build();
+    assertTrue(
+        cfg.getString("gerrit", null, "canonicalWebUrl").equals("http://gerrit.example.com"));
+  }
+
+  private Gerrit createGerrit(String configText) {
+    GerritSpec gerritSpec = new GerritSpec();
+    gerritSpec.setConfigFiles(Map.of("gerrit.config", configText));
+    Gerrit gerrit = new Gerrit();
+    gerrit.setSpec(gerritSpec);
+    return gerrit;
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/gitgc/GitGarbageCollectionE2E.java b/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/gitgc/GitGarbageCollectionE2E.java
new file mode 100644
index 0000000..7f31ac2
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/gitgc/GitGarbageCollectionE2E.java
@@ -0,0 +1,234 @@
+// 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.gitgc;
+
+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.CoreMatchers.not;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.notNullValue;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.k8s.operator.network.IngressType;
+import com.google.gerrit.k8s.operator.test.AbstractGerritOperatorE2ETest;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.gitgc.GitGarbageCollection;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.gitgc.GitGarbageCollectionSpec;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.gitgc.GitGarbageCollectionStatus;
+import io.fabric8.kubernetes.api.model.ObjectMetaBuilder;
+import io.fabric8.kubernetes.api.model.batch.v1.CronJob;
+import io.fabric8.kubernetes.api.model.batch.v1.Job;
+import java.util.List;
+import java.util.Set;
+import org.junit.jupiter.api.Test;
+
+public class GitGarbageCollectionE2E extends AbstractGerritOperatorE2ETest {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+  static final String GITGC_SCHEDULE = "*/1 * * * *";
+
+  @Test
+  void testGitGcAllProjectsCreationAndDeletion() {
+    GitGarbageCollection gitGc = createCompleteGc();
+
+    logger.atInfo().log("Waiting max 2 minutes for GitGc to be created.");
+    await()
+        .atMost(2, MINUTES)
+        .untilAsserted(
+            () -> {
+              assertGitGcCreation(gitGc.getMetadata().getName());
+              assertGitGcCronJobCreation(gitGc.getMetadata().getName());
+              assertGitGcJobCreation(gitGc.getMetadata().getName());
+            });
+
+    logger.atInfo().log("Deleting test GitGc object: %s", gitGc);
+    client.resource(gitGc).delete();
+    awaitGitGcDeletionAssertion(gitGc.getMetadata().getName());
+  }
+
+  @Test
+  void testGitGcSelectedProjects() {
+    GitGarbageCollection gitGc = createSelectiveGc("selective-gc", Set.of("All-Projects", "test"));
+
+    logger.atInfo().log("Waiting max 2 minutes for GitGc to be created.");
+    await()
+        .atMost(2, MINUTES)
+        .untilAsserted(
+            () -> {
+              assertGitGcCreation(gitGc.getMetadata().getName());
+              assertGitGcCronJobCreation(gitGc.getMetadata().getName());
+              assertGitGcJobCreation(gitGc.getMetadata().getName());
+            });
+
+    client.resource(gitGc).delete();
+  }
+
+  @Test
+  void testSelectiveGcIsExcludedFromCompleteGc() {
+    GitGarbageCollection completeGitGc = createCompleteGc();
+
+    logger.atInfo().log("Waiting max 2 minutes for GitGc to be created.");
+    await()
+        .atMost(2, MINUTES)
+        .untilAsserted(
+            () -> {
+              assertGitGcCreation(completeGitGc.getMetadata().getName());
+              assertGitGcCronJobCreation(completeGitGc.getMetadata().getName());
+            });
+
+    Set<String> selectedProjects = Set.of("All-Projects", "test");
+    GitGarbageCollection selectiveGitGc = createSelectiveGc("selective-gc", selectedProjects);
+
+    logger.atInfo().log("Waiting max 2 minutes for GitGc to be created.");
+    await()
+        .atMost(2, MINUTES)
+        .untilAsserted(
+            () -> {
+              assertGitGcCreation(selectiveGitGc.getMetadata().getName());
+              assertGitGcCronJobCreation(selectiveGitGc.getMetadata().getName());
+            });
+
+    await()
+        .atMost(2, MINUTES)
+        .untilAsserted(
+            () -> {
+              GitGarbageCollection updatedCompleteGitGc =
+                  client
+                      .resources(GitGarbageCollection.class)
+                      .inNamespace(operator.getNamespace())
+                      .withName(completeGitGc.getMetadata().getName())
+                      .get();
+              assert updatedCompleteGitGc
+                  .getStatus()
+                  .getExcludedProjects()
+                  .containsAll(selectedProjects);
+            });
+
+    client.resource(selectiveGitGc).delete();
+    awaitGitGcDeletionAssertion(selectiveGitGc.getMetadata().getName());
+
+    await()
+        .atMost(2, MINUTES)
+        .untilAsserted(
+            () -> {
+              GitGarbageCollection updatedCompleteGitGc =
+                  client
+                      .resources(GitGarbageCollection.class)
+                      .inNamespace(operator.getNamespace())
+                      .withName(completeGitGc.getMetadata().getName())
+                      .get();
+              assert updatedCompleteGitGc.getStatus().getExcludedProjects().isEmpty();
+            });
+  }
+
+  private GitGarbageCollection createCompleteGc() {
+    GitGarbageCollection gitGc = new GitGarbageCollection();
+    gitGc.setMetadata(
+        new ObjectMetaBuilder()
+            .withName("gitgc-complete")
+            .withNamespace(operator.getNamespace())
+            .build());
+    GitGarbageCollectionSpec spec = new GitGarbageCollectionSpec();
+    spec.setSchedule(GITGC_SCHEDULE);
+    spec.setCluster(CLUSTER_NAME);
+    gitGc.setSpec(spec);
+
+    logger.atInfo().log("Creating test GitGc object: %s", gitGc);
+    client.resource(gitGc).createOrReplace();
+
+    return gitGc;
+  }
+
+  private GitGarbageCollection createSelectiveGc(String name, Set<String> projects) {
+    GitGarbageCollection gitGc = new GitGarbageCollection();
+    gitGc.setMetadata(
+        new ObjectMetaBuilder().withName(name).withNamespace(operator.getNamespace()).build());
+    GitGarbageCollectionSpec spec = new GitGarbageCollectionSpec();
+    spec.setSchedule(GITGC_SCHEDULE);
+    spec.setCluster(CLUSTER_NAME);
+    spec.setProjects(projects);
+    gitGc.setSpec(spec);
+
+    logger.atInfo().log("Creating test GitGc object: %s", gitGc);
+    client.resource(gitGc).createOrReplace();
+
+    return gitGc;
+  }
+
+  private void assertGitGcCreation(String gitGcName) {
+    GitGarbageCollection updatedGitGc =
+        client
+            .resources(GitGarbageCollection.class)
+            .inNamespace(operator.getNamespace())
+            .withName(gitGcName)
+            .get();
+    assertThat(updatedGitGc, is(notNullValue()));
+    assertThat(
+        updatedGitGc.getStatus().getState(),
+        is(not(equalTo(GitGarbageCollectionStatus.GitGcState.ERROR))));
+  }
+
+  private void assertGitGcCronJobCreation(String gitGcName) {
+    CronJob cronJob =
+        client
+            .batch()
+            .v1()
+            .cronjobs()
+            .inNamespace(operator.getNamespace())
+            .withName(gitGcName)
+            .get();
+    assertThat(cronJob, is(notNullValue()));
+  }
+
+  private void awaitGitGcDeletionAssertion(String gitGcName) {
+    logger.atInfo().log("Waiting max 2 minutes for GitGc to be deleted.");
+    await()
+        .atMost(2, MINUTES)
+        .untilAsserted(
+            () -> {
+              GitGarbageCollection updatedGitGc =
+                  client
+                      .resources(GitGarbageCollection.class)
+                      .inNamespace(operator.getNamespace())
+                      .withName(gitGcName)
+                      .get();
+              assertNull(updatedGitGc);
+
+              CronJob cronJob =
+                  client
+                      .batch()
+                      .v1()
+                      .cronjobs()
+                      .inNamespace(operator.getNamespace())
+                      .withName(gitGcName)
+                      .get();
+              assertNull(cronJob);
+            });
+  }
+
+  private void assertGitGcJobCreation(String gitGcName) {
+    List<Job> jobRuns =
+        client.batch().v1().jobs().inNamespace(operator.getNamespace()).list().getItems();
+    assert (jobRuns.size() > 0);
+    assert (jobRuns.get(0).getMetadata().getName().startsWith(gitGcName));
+  }
+
+  @Override
+  protected IngressType getIngressType() {
+    return IngressType.NONE;
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/network/ambassador/dependent/GerritClusterAmbassadorTest.java b/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/network/ambassador/dependent/GerritClusterAmbassadorTest.java
new file mode 100644
index 0000000..3f5a9cd
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/network/ambassador/dependent/GerritClusterAmbassadorTest.java
@@ -0,0 +1,130 @@
+// Copyright (C) 2023 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.network.ambassador.dependent;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.k8s.operator.v1alpha.api.model.network.GerritNetwork;
+import io.getambassador.v2.Host;
+import io.getambassador.v2.Mapping;
+import io.getambassador.v2.TLSContext;
+import io.javaoperatorsdk.operator.ReconcilerUtils;
+import java.lang.reflect.InvocationTargetException;
+import java.util.Map;
+import java.util.stream.Stream;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+public class GerritClusterAmbassadorTest {
+
+  @ParameterizedTest
+  @MethodSource("provideYamlManifests")
+  public void expectedGerritClusterAmbassadorComponentsCreated(
+      String inputFile, Map<String, String> expectedOutputFileNames)
+      throws ClassNotFoundException, NoSuchMethodException, InstantiationException,
+          IllegalAccessException, InvocationTargetException {
+    GerritNetwork gerritNetwork =
+        ReconcilerUtils.loadYaml(GerritNetwork.class, this.getClass(), inputFile);
+
+    for (Map.Entry<String, String> entry : expectedOutputFileNames.entrySet()) {
+      String className = entry.getKey();
+      String expectedOutputFile = entry.getValue();
+
+      Class<?> clazz = Class.forName(className);
+      Object dependentObject = clazz.getDeclaredConstructor(new Class[] {}).newInstance();
+
+      if (dependentObject instanceof MappingDependentResourceInterface) {
+        MappingDependentResourceInterface dependent =
+            (MappingDependentResourceInterface) dependentObject;
+        Mapping result = dependent.desired(gerritNetwork, null);
+        Mapping expected =
+            ReconcilerUtils.loadYaml(Mapping.class, this.getClass(), expectedOutputFile);
+        assertThat(result.getSpec()).isEqualTo(expected.getSpec());
+      } else if (dependentObject instanceof GerritClusterTLSContext) {
+        GerritClusterTLSContext dependent = (GerritClusterTLSContext) dependentObject;
+        TLSContext result = dependent.desired(gerritNetwork, null);
+        TLSContext expected =
+            ReconcilerUtils.loadYaml(TLSContext.class, this.getClass(), expectedOutputFile);
+        assertThat(result.getSpec()).isEqualTo(expected.getSpec());
+      } else if (dependentObject instanceof GerritClusterHost) {
+        GerritClusterHost dependent = (GerritClusterHost) dependentObject;
+        Host result = dependent.desired(gerritNetwork, null);
+        Host expected = ReconcilerUtils.loadYaml(Host.class, this.getClass(), expectedOutputFile);
+        assertThat(result.getSpec()).isEqualTo(expected.getSpec());
+      }
+    }
+  }
+
+  private static Stream<Arguments> provideYamlManifests() {
+    return Stream.of(
+        Arguments.of(
+            "../../gerritnetwork_primary_replica_tls.yaml",
+            Map.of(
+                GerritClusterMappingGETReplica.class.getName(),
+                    "mappingGETReplica_primary_replica.yaml",
+                GerritClusterMappingPOSTReplica.class.getName(),
+                    "mappingPOSTReplica_primary_replica.yaml",
+                GerritClusterMappingPrimary.class.getName(), "mappingPrimary_primary_replica.yaml",
+                GerritClusterTLSContext.class.getName(), "tlscontext.yaml")),
+        Arguments.of(
+            "../../gerritnetwork_primary_replica_tls_create_host.yaml",
+            Map.of(
+                GerritClusterMappingGETReplica.class.getName(),
+                    "mappingGETReplica_primary_replica.yaml",
+                GerritClusterMappingPOSTReplica.class.getName(),
+                    "mappingPOSTReplica_primary_replica.yaml",
+                GerritClusterMappingPrimary.class.getName(), "mappingPrimary_primary_replica.yaml",
+                GerritClusterTLSContext.class.getName(), "tlscontext.yaml",
+                GerritClusterHost.class.getName(), "host_with_tls.yaml")),
+        Arguments.of(
+            "../../gerritnetwork_primary_replica.yaml",
+            Map.of(
+                GerritClusterMappingGETReplica.class.getName(),
+                    "mappingGETReplica_primary_replica.yaml",
+                GerritClusterMappingPOSTReplica.class.getName(),
+                    "mappingPOSTReplica_primary_replica.yaml",
+                GerritClusterMappingPrimary.class.getName(),
+                    "mappingPrimary_primary_replica.yaml")),
+        Arguments.of(
+            "../../gerritnetwork_primary_replica_create_host.yaml",
+            Map.of(
+                GerritClusterMappingGETReplica.class.getName(),
+                    "mappingGETReplica_primary_replica.yaml",
+                GerritClusterMappingPOSTReplica.class.getName(),
+                    "mappingPOSTReplica_primary_replica.yaml",
+                GerritClusterMappingPrimary.class.getName(), "mappingPrimary_primary_replica.yaml",
+                GerritClusterHost.class.getName(), "host.yaml")),
+        Arguments.of(
+            "../../gerritnetwork_primary.yaml",
+            Map.of(GerritClusterMapping.class.getName(), "mapping_primary.yaml")),
+        Arguments.of(
+            "../../gerritnetwork_replica.yaml",
+            Map.of(GerritClusterMapping.class.getName(), "mapping_replica.yaml")),
+        Arguments.of(
+            "../../gerritnetwork_receiver_replica.yaml",
+            Map.of(
+                GerritClusterMapping.class.getName(), "mapping_replica.yaml",
+                GerritClusterMappingReceiver.class.getName(), "mapping_receiver.yaml",
+                GerritClusterMappingReceiverGET.class.getName(), "mappingGET_receiver.yaml")),
+        Arguments.of(
+            "../../gerritnetwork_receiver_replica_tls.yaml",
+            Map.of(
+                GerritClusterMapping.class.getName(), "mapping_replica.yaml",
+                GerritClusterMappingReceiver.class.getName(), "mapping_receiver.yaml",
+                GerritClusterMappingReceiverGET.class.getName(), "mappingGET_receiver.yaml",
+                GerritClusterTLSContext.class.getName(), "tlscontext.yaml")));
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/network/ingress/dependent/GerritClusterIngressTest.java b/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/network/ingress/dependent/GerritClusterIngressTest.java
new file mode 100644
index 0000000..a2e6a24
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/network/ingress/dependent/GerritClusterIngressTest.java
@@ -0,0 +1,52 @@
+// Copyright (C) 2023 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.network.ingress.dependent;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.k8s.operator.v1alpha.api.model.network.GerritNetwork;
+import io.fabric8.kubernetes.api.model.networking.v1.Ingress;
+import io.javaoperatorsdk.operator.ReconcilerUtils;
+import java.util.stream.Stream;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+public class GerritClusterIngressTest {
+  @ParameterizedTest
+  @MethodSource("provideYamlManifests")
+  public void expectedGerritClusterIngressCreated(String inputFile, String expectedOutputFile) {
+    GerritClusterIngress dependent = new GerritClusterIngress();
+    Ingress result =
+        dependent.desired(
+            ReconcilerUtils.loadYaml(GerritNetwork.class, this.getClass(), inputFile), null);
+    Ingress expected = ReconcilerUtils.loadYaml(Ingress.class, this.getClass(), expectedOutputFile);
+    assertThat(result.getSpec()).isEqualTo(expected.getSpec());
+    assertThat(result.getMetadata().getAnnotations())
+        .containsExactlyEntriesIn(expected.getMetadata().getAnnotations());
+  }
+
+  private static Stream<Arguments> provideYamlManifests() {
+    return Stream.of(
+        Arguments.of(
+            "../../gerritnetwork_primary_replica_tls.yaml", "ingress_primary_replica_tls.yaml"),
+        Arguments.of("../../gerritnetwork_primary_replica.yaml", "ingress_primary_replica.yaml"),
+        Arguments.of("../../gerritnetwork_primary.yaml", "ingress_primary.yaml"),
+        Arguments.of("../../gerritnetwork_replica.yaml", "ingress_replica.yaml"),
+        Arguments.of("../../gerritnetwork_receiver_replica.yaml", "ingress_receiver_replica.yaml"),
+        Arguments.of(
+            "../../gerritnetwork_receiver_replica_tls.yaml", "ingress_receiver_replica_tls.yaml"));
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/network/istio/dependent/GerritClusterIstioTest.java b/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/network/istio/dependent/GerritClusterIstioTest.java
new file mode 100644
index 0000000..eaed636
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/network/istio/dependent/GerritClusterIstioTest.java
@@ -0,0 +1,88 @@
+// Copyright (C) 2023 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.network.istio.dependent;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.k8s.operator.v1alpha.api.model.network.GerritNetwork;
+import io.fabric8.istio.api.networking.v1beta1.Gateway;
+import io.fabric8.istio.api.networking.v1beta1.VirtualService;
+import io.javaoperatorsdk.operator.ReconcilerUtils;
+import java.util.stream.Stream;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+public class GerritClusterIstioTest {
+  @ParameterizedTest
+  @MethodSource("provideYamlManifests")
+  public void expectedGerritClusterIstioComponentsCreated(
+      String inputFile, String expectedGatewayOutputFile, String expectedVirtualServiceOutputFile) {
+    GerritNetwork gerritNetwork =
+        ReconcilerUtils.loadYaml(GerritNetwork.class, this.getClass(), inputFile);
+    GerritClusterIstioGateway gatewayDependent = new GerritClusterIstioGateway();
+    Gateway gatewayResult = gatewayDependent.desired(gerritNetwork, null);
+    Gateway expectedGateway =
+        ReconcilerUtils.loadYaml(Gateway.class, this.getClass(), expectedGatewayOutputFile);
+    assertThat(gatewayResult.getSpec()).isEqualTo(expectedGateway.getSpec());
+
+    GerritIstioVirtualService virtualServiceDependent = new GerritIstioVirtualService();
+    VirtualService virtualServiceResult = virtualServiceDependent.desired(gerritNetwork, null);
+    VirtualService expectedVirtualService =
+        ReconcilerUtils.loadYaml(
+            VirtualService.class, this.getClass(), expectedVirtualServiceOutputFile);
+    assertThat(virtualServiceResult.getSpec()).isEqualTo(expectedVirtualService.getSpec());
+  }
+
+  private static Stream<Arguments> provideYamlManifests() {
+    return Stream.of(
+        Arguments.of(
+            "../../gerritnetwork_primary_replica_tls.yaml",
+            "gateway_tls.yaml",
+            "virtualservice_primary_replica.yaml"),
+        Arguments.of(
+            "../../gerritnetwork_primary_replica.yaml",
+            "gateway.yaml",
+            "virtualservice_primary_replica.yaml"),
+        Arguments.of(
+            "../../gerritnetwork_primary.yaml", "gateway.yaml", "virtualservice_primary.yaml"),
+        Arguments.of(
+            "../../gerritnetwork_replica.yaml", "gateway.yaml", "virtualservice_replica.yaml"),
+        Arguments.of(
+            "../../gerritnetwork_receiver_replica.yaml",
+            "gateway.yaml",
+            "virtualservice_receiver_replica.yaml"),
+        Arguments.of(
+            "../../gerritnetwork_receiver_replica_tls.yaml",
+            "gateway_tls.yaml",
+            "virtualservice_receiver_replica.yaml"),
+        Arguments.of(
+            "../../gerritnetwork_primary_ssh.yaml",
+            "gateway_primary_ssh.yaml",
+            "virtualservice_primary_ssh.yaml"),
+        Arguments.of(
+            "../../gerritnetwork_replica_ssh.yaml",
+            "gateway_replica_ssh.yaml",
+            "virtualservice_replica_ssh.yaml"),
+        Arguments.of(
+            "../../gerritnetwork_primary_replica_ssh.yaml",
+            "gateway_primary_replica_ssh.yaml",
+            "virtualservice_primary_replica_ssh.yaml"),
+        Arguments.of(
+            "../../gerritnetwork_receiver_replica_ssh.yaml",
+            "gateway_receiver_replica_ssh.yaml",
+            "virtualservice_receiver_replica_ssh.yaml"));
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/receiver/AbstractClusterManagedReceiverE2E.java b/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/receiver/AbstractClusterManagedReceiverE2E.java
new file mode 100644
index 0000000..557e140
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/receiver/AbstractClusterManagedReceiverE2E.java
@@ -0,0 +1,110 @@
+// 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 org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.equalTo;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.google.gerrit.k8s.operator.test.AbstractGerritOperatorE2ETest;
+import com.google.gerrit.k8s.operator.test.ReceiverUtil;
+import com.google.gerrit.k8s.operator.test.TestGerrit;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.cluster.GerritCluster;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.gerrit.GerritTemplate;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.gerrit.GerritTemplateSpec.GerritMode;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.receiver.ReceiverTemplate;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.receiver.ReceiverTemplateSpec;
+import io.fabric8.kubernetes.api.model.ObjectMeta;
+import io.fabric8.kubernetes.api.model.ObjectMetaBuilder;
+import java.io.File;
+import java.net.URL;
+import java.nio.file.Path;
+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.UsernamePasswordCredentialsProvider;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+public abstract class AbstractClusterManagedReceiverE2E extends AbstractGerritOperatorE2ETest {
+  private static final String GERRIT_NAME = "gerrit";
+  private ReceiverTemplate receiver;
+  private GerritTemplate gerrit;
+
+  @BeforeEach
+  public void setupComponents() throws Exception {
+    gerrit = TestGerrit.createGerritTemplate(GERRIT_NAME, GerritMode.REPLICA);
+    gerritCluster.addGerrit(gerrit);
+
+    receiver = new ReceiverTemplate();
+    ObjectMeta receiverMeta = new ObjectMetaBuilder().withName("receiver").build();
+    receiver.setMetadata(receiverMeta);
+    ReceiverTemplateSpec receiverTemplateSpec = new ReceiverTemplateSpec();
+    receiverTemplateSpec.setReplicas(2);
+    receiverTemplateSpec.setCredentialSecretRef(ReceiverUtil.CREDENTIALS_SECRET_NAME);
+    receiver.setSpec(receiverTemplateSpec);
+    gerritCluster.setReceiver(receiver);
+    gerritCluster.deploy();
+  }
+
+  @Test
+  public void testProjectLifecycle(@TempDir Path tempDir) throws Exception {
+    GerritCluster cluster = gerritCluster.getGerritCluster();
+    assertProjectLifecycle(cluster, tempDir);
+  }
+
+  private void assertProjectLifecycle(GerritCluster cluster, Path tempDir) throws Exception {
+    assertThat(
+        ReceiverUtil.sendReceiverApiRequest(cluster, "PUT", "/a/projects/test.git"),
+        is(equalTo(201)));
+    CredentialsProvider gerritCredentials =
+        new UsernamePasswordCredentialsProvider(
+            testProps.getGerritUser(), testProps.getGerritPwd());
+    Git git =
+        Git.cloneRepository()
+            .setURI(getGerritUrl("/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.push()
+        .setCredentialsProvider(
+            new UsernamePasswordCredentialsProvider(
+                ReceiverUtil.RECEIVER_TEST_USER, ReceiverUtil.RECEIVER_TEST_PASSWORD))
+        .setRefSpecs(new RefSpec("refs/heads/master"))
+        .call();
+    assertTrue(
+        git.lsRemote().setCredentialsProvider(gerritCredentials).setRemote("origin").call().stream()
+            .anyMatch(ref -> ref.getObjectId().equals(commit.getId())));
+    assertThat(
+        ReceiverUtil.sendReceiverApiRequest(cluster, "DELETE", "/a/projects/test.git"),
+        is(equalTo(204)));
+  }
+
+  private URL getGerritUrl(String path) throws Exception {
+    return new URIBuilder()
+        .setScheme("https")
+        .setHost(gerritCluster.getHostname())
+        .setPath(path)
+        .build()
+        .toURL();
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/receiver/ClusterManagedReceiverWithIngressE2E.java b/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/receiver/ClusterManagedReceiverWithIngressE2E.java
new file mode 100644
index 0000000..4dd8bd3
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/receiver/ClusterManagedReceiverWithIngressE2E.java
@@ -0,0 +1,25 @@
+// 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.network.IngressType;
+
+public class ClusterManagedReceiverWithIngressE2E extends AbstractClusterManagedReceiverE2E {
+
+  @Override
+  protected IngressType getIngressType() {
+    return IngressType.INGRESS;
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/receiver/ClusterManagedReceiverWithIstioE2E.java b/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/receiver/ClusterManagedReceiverWithIstioE2E.java
new file mode 100644
index 0000000..e76303c
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/receiver/ClusterManagedReceiverWithIstioE2E.java
@@ -0,0 +1,24 @@
+// 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.network.IngressType;
+
+public class ClusterManagedReceiverWithIstioE2E extends AbstractClusterManagedReceiverE2E {
+  @Override
+  protected IngressType getIngressType() {
+    return IngressType.ISTIO;
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/receiver/dependent/ReceiverTest.java b/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/receiver/dependent/ReceiverTest.java
new file mode 100644
index 0000000..8698b1a
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/receiver/dependent/ReceiverTest.java
@@ -0,0 +1,48 @@
+// Copyright (C) 2023 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.dependent;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.k8s.operator.v1alpha.api.model.receiver.Receiver;
+import io.fabric8.kubernetes.api.model.Service;
+import io.fabric8.kubernetes.api.model.apps.Deployment;
+import io.javaoperatorsdk.operator.ReconcilerUtils;
+import java.util.stream.Stream;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+public class ReceiverTest {
+  @ParameterizedTest
+  @MethodSource("provideYamlManifests")
+  public void expectedReceiverComponentsCreated(
+      String inputFile, String expectedDeployment, String expectedService) {
+    Receiver input = ReconcilerUtils.loadYaml(Receiver.class, this.getClass(), inputFile);
+    ReceiverDeployment dependentDeployment = new ReceiverDeployment();
+    assertThat(dependentDeployment.desired(input, null))
+        .isEqualTo(ReconcilerUtils.loadYaml(Deployment.class, this.getClass(), expectedDeployment));
+
+    ReceiverService dependentService = new ReceiverService();
+    assertThat(dependentService.desired(input, null))
+        .isEqualTo(ReconcilerUtils.loadYaml(Service.class, this.getClass(), expectedService));
+  }
+
+  private static Stream<Arguments> provideYamlManifests() {
+    return Stream.of(
+        Arguments.of("../receiver.yaml", "deployment.yaml", "service.yaml"),
+        Arguments.of("../receiver_minimal.yaml", "deployment_minimal.yaml", "service.yaml"));
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/server/GerritAdmissionWebhookTest.java b/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/server/GerritAdmissionWebhookTest.java
new file mode 100644
index 0000000..32b342d
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/server/GerritAdmissionWebhookTest.java
@@ -0,0 +1,182 @@
+// Copyright (C) 2023 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.server;
+
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.equalTo;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.google.gerrit.k8s.operator.test.TestAdmissionWebhookServer;
+import com.google.gerrit.k8s.operator.v1alpha.admission.servlet.GerritAdmissionWebhook;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.cluster.GerritCluster;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.cluster.GerritClusterSpec;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.gerrit.Gerrit;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.gerrit.GerritSpec;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.gerrit.GerritTemplateSpec.GerritMode;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.receiver.Receiver;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.shared.GerritClusterIngressConfig;
+import io.fabric8.kubernetes.api.model.DefaultKubernetesResourceList;
+import io.fabric8.kubernetes.api.model.HasMetadata;
+import io.fabric8.kubernetes.api.model.ObjectMeta;
+import io.fabric8.kubernetes.api.model.ObjectMetaBuilder;
+import io.fabric8.kubernetes.api.model.admission.v1.AdmissionRequest;
+import io.fabric8.kubernetes.api.model.admission.v1.AdmissionReview;
+import io.fabric8.kubernetes.client.server.mock.KubernetesServer;
+import io.fabric8.kubernetes.internal.KubernetesDeserializer;
+import jakarta.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.HttpURLConnection;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.util.Map;
+import org.apache.commons.lang3.RandomStringUtils;
+import org.eclipse.jetty.http.HttpMethod;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Rule;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInstance;
+import org.junit.jupiter.api.TestInstance.Lifecycle;
+
+@TestInstance(Lifecycle.PER_CLASS)
+public class GerritAdmissionWebhookTest {
+  private static final String NAMESPACE = "test";
+  private static final String LIST_GERRITS_PATH =
+      String.format(
+          "/apis/%s/namespaces/%s/%s",
+          HasMetadata.getApiVersion(Gerrit.class), NAMESPACE, HasMetadata.getPlural(Gerrit.class));
+  private static final String LIST_GERRIT_CLUSTERS_PATH =
+      String.format(
+          "/apis/%s/namespaces/%s/%s",
+          HasMetadata.getApiVersion(GerritCluster.class),
+          NAMESPACE,
+          HasMetadata.getPlural(GerritCluster.class));
+  private TestAdmissionWebhookServer server;
+
+  @Rule public KubernetesServer kubernetesServer = new KubernetesServer();
+
+  @BeforeAll
+  public void setup() throws Exception {
+    KubernetesDeserializer.registerCustomKind(
+        "gerritoperator.google.com/v1alpha2", "Gerrit", Gerrit.class);
+    KubernetesDeserializer.registerCustomKind(
+        "gerritoperator.google.com/v1alpha1", "Receiver", Receiver.class);
+    server = new TestAdmissionWebhookServer();
+
+    kubernetesServer.before();
+
+    GerritAdmissionWebhook webhook = new GerritAdmissionWebhook();
+    server.registerWebhook(webhook);
+    server.start();
+  }
+
+  @Test
+  public void testInvalidGerritConfigRejected() throws Exception {
+    String clusterName = "gerrit";
+    Config gerritConfig = new Config();
+    gerritConfig.setString("container", null, "user", "gerrit");
+    Gerrit gerrit = createGerrit(clusterName, gerritConfig);
+    kubernetesServer
+        .expect()
+        .get()
+        .withPath(LIST_GERRITS_PATH)
+        .andReturn(HttpURLConnection.HTTP_OK, new DefaultKubernetesResourceList<Gerrit>())
+        .times(2);
+
+    mockGerritCluster(clusterName);
+
+    HttpURLConnection http = sendAdmissionRequest(gerrit);
+
+    AdmissionReview response =
+        new ObjectMapper().readValue(http.getInputStream(), AdmissionReview.class);
+
+    assertThat(http.getResponseCode(), is(equalTo(HttpServletResponse.SC_OK)));
+    assertThat(response.getResponse().getAllowed(), is(true));
+
+    gerritConfig.setString("container", null, "user", "invalid");
+    Gerrit gerrit2 = createGerrit(clusterName, gerritConfig);
+    HttpURLConnection http2 = sendAdmissionRequest(gerrit2);
+
+    AdmissionReview response2 =
+        new ObjectMapper().readValue(http2.getInputStream(), AdmissionReview.class);
+
+    assertThat(http2.getResponseCode(), is(equalTo(HttpServletResponse.SC_OK)));
+    assertThat(response2.getResponse().getAllowed(), is(false));
+  }
+
+  private void mockGerritCluster(String name) {
+    GerritCluster cluster = new GerritCluster();
+    cluster.setMetadata(new ObjectMetaBuilder().withName(name).withNamespace(NAMESPACE).build());
+    GerritClusterSpec clusterSpec = new GerritClusterSpec();
+    GerritClusterIngressConfig ingressConfig = new GerritClusterIngressConfig();
+    ingressConfig.setEnabled(false);
+    clusterSpec.setIngress(ingressConfig);
+    clusterSpec.setServerId("test");
+    cluster.setSpec(clusterSpec);
+
+    kubernetesServer
+        .expect()
+        .get()
+        .withPath(LIST_GERRIT_CLUSTERS_PATH + "/" + name)
+        .andReturn(HttpURLConnection.HTTP_OK, cluster)
+        .always();
+  }
+
+  private Gerrit createGerrit(String cluster, Config gerritConfig) {
+    ObjectMeta meta =
+        new ObjectMetaBuilder()
+            .withName(RandomStringUtils.random(10))
+            .withNamespace(NAMESPACE)
+            .build();
+    GerritSpec gerritSpec = new GerritSpec();
+    gerritSpec.setMode(GerritMode.PRIMARY);
+    if (gerritConfig != null) {
+      gerritSpec.setConfigFiles(Map.of("gerrit.config", gerritConfig.toText()));
+    }
+    Gerrit gerrit = new Gerrit();
+    gerrit.setMetadata(meta);
+    gerrit.setSpec(gerritSpec);
+    return gerrit;
+  }
+
+  private HttpURLConnection sendAdmissionRequest(Gerrit gerrit)
+      throws MalformedURLException, IOException {
+    HttpURLConnection http =
+        (HttpURLConnection)
+            new URL("http://localhost:8080/admission/v1alpha/gerrit").openConnection();
+    http.setRequestMethod(HttpMethod.POST.asString());
+    http.setRequestProperty("Content-Type", "application/json");
+    http.setDoOutput(true);
+
+    AdmissionRequest admissionReq = new AdmissionRequest();
+    admissionReq.setObject(gerrit);
+    AdmissionReview admissionReview = new AdmissionReview();
+    admissionReview.setRequest(admissionReq);
+
+    try (OutputStream os = http.getOutputStream()) {
+      byte[] input = new ObjectMapper().writer().writeValueAsBytes(admissionReview);
+      os.write(input, 0, input.length);
+    }
+    return http;
+  }
+
+  @AfterAll
+  public void shutdown() throws Exception {
+    server.stop();
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/server/GerritClusterAdmissionWebhookTest.java b/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/server/GerritClusterAdmissionWebhookTest.java
new file mode 100644
index 0000000..55526c7
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/server/GerritClusterAdmissionWebhookTest.java
@@ -0,0 +1,195 @@
+// Copyright (C) 2023 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.server;
+
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.equalTo;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.google.gerrit.k8s.operator.test.ReceiverUtil;
+import com.google.gerrit.k8s.operator.test.TestAdmissionWebhookServer;
+import com.google.gerrit.k8s.operator.test.TestGerrit;
+import com.google.gerrit.k8s.operator.test.TestGerritCluster;
+import com.google.gerrit.k8s.operator.v1alpha.admission.servlet.GerritAdmissionWebhook;
+import com.google.gerrit.k8s.operator.v1alpha.admission.servlet.GerritClusterAdmissionWebhook;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.cluster.GerritCluster;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.gerrit.Gerrit;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.gerrit.GerritTemplate;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.gerrit.GerritTemplateSpec.GerritMode;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.receiver.Receiver;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.receiver.ReceiverTemplate;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.receiver.ReceiverTemplateSpec;
+import io.fabric8.kubernetes.api.model.ObjectMeta;
+import io.fabric8.kubernetes.api.model.ObjectMetaBuilder;
+import io.fabric8.kubernetes.api.model.admission.v1.AdmissionRequest;
+import io.fabric8.kubernetes.api.model.admission.v1.AdmissionReview;
+import io.fabric8.kubernetes.client.server.mock.KubernetesServer;
+import io.fabric8.kubernetes.internal.KubernetesDeserializer;
+import jakarta.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.HttpURLConnection;
+import java.net.MalformedURLException;
+import java.net.URL;
+import org.eclipse.jetty.http.HttpMethod;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Rule;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInstance;
+import org.junit.jupiter.api.TestInstance.Lifecycle;
+
+@TestInstance(Lifecycle.PER_CLASS)
+public class GerritClusterAdmissionWebhookTest {
+  private static final String NAMESPACE = "test";
+  private TestAdmissionWebhookServer server;
+
+  @Rule public KubernetesServer kubernetesServer = new KubernetesServer();
+
+  @BeforeAll
+  public void setup() throws Exception {
+    KubernetesDeserializer.registerCustomKind(
+        "gerritoperator.google.com/v1alpha2", "Gerrit", Gerrit.class);
+    KubernetesDeserializer.registerCustomKind(
+        "gerritoperator.google.com/v1alpha2", "GerritCluster", GerritCluster.class);
+    KubernetesDeserializer.registerCustomKind(
+        "gerritoperator.google.com/v1alpha2", "Receiver", Receiver.class);
+    server = new TestAdmissionWebhookServer();
+
+    kubernetesServer.before();
+
+    server.registerWebhook(new GerritClusterAdmissionWebhook());
+    server.registerWebhook(new GerritAdmissionWebhook());
+    server.start();
+  }
+
+  @Test
+  public void testOnlySinglePrimaryGerritIsAcceptedPerGerritCluster() throws Exception {
+    Config cfg = new Config();
+    cfg.fromText(TestGerrit.DEFAULT_GERRIT_CONFIG);
+    GerritTemplate gerrit1 = TestGerrit.createGerritTemplate("gerrit1", GerritMode.PRIMARY, cfg);
+    TestGerritCluster gerritCluster =
+        new TestGerritCluster(kubernetesServer.getClient(), NAMESPACE);
+    gerritCluster.addGerrit(gerrit1);
+    GerritCluster cluster = gerritCluster.build();
+
+    HttpURLConnection http = sendAdmissionRequest(cluster);
+
+    AdmissionReview response =
+        new ObjectMapper().readValue(http.getInputStream(), AdmissionReview.class);
+
+    assertThat(http.getResponseCode(), is(equalTo(HttpServletResponse.SC_OK)));
+    assertThat(response.getResponse().getAllowed(), is(true));
+
+    GerritTemplate gerrit2 = TestGerrit.createGerritTemplate("gerrit2", GerritMode.PRIMARY, cfg);
+    gerritCluster.addGerrit(gerrit2);
+    HttpURLConnection http2 = sendAdmissionRequest(gerritCluster.build());
+
+    AdmissionReview response2 =
+        new ObjectMapper().readValue(http2.getInputStream(), AdmissionReview.class);
+
+    assertThat(http2.getResponseCode(), is(equalTo(HttpServletResponse.SC_OK)));
+    assertThat(response2.getResponse().getAllowed(), is(false));
+    assertThat(
+        response2.getResponse().getStatus().getCode(),
+        is(equalTo(HttpServletResponse.SC_CONFLICT)));
+  }
+
+  @Test
+  public void testPrimaryGerritAndReceiverAreNotAcceptedInSameGerritCluster() throws Exception {
+    Config cfg = new Config();
+    cfg.fromText(TestGerrit.DEFAULT_GERRIT_CONFIG);
+    GerritTemplate gerrit = TestGerrit.createGerritTemplate("gerrit1", GerritMode.PRIMARY, cfg);
+    TestGerritCluster gerritCluster =
+        new TestGerritCluster(kubernetesServer.getClient(), NAMESPACE);
+    gerritCluster.addGerrit(gerrit);
+
+    ReceiverTemplate receiver = new ReceiverTemplate();
+    ObjectMeta receiverMeta = new ObjectMetaBuilder().withName("receiver").build();
+    receiver.setMetadata(receiverMeta);
+    ReceiverTemplateSpec receiverTemplateSpec = new ReceiverTemplateSpec();
+    receiverTemplateSpec.setReplicas(2);
+    receiverTemplateSpec.setCredentialSecretRef(ReceiverUtil.CREDENTIALS_SECRET_NAME);
+    receiver.setSpec(receiverTemplateSpec);
+
+    gerritCluster.setReceiver(receiver);
+    HttpURLConnection http2 = sendAdmissionRequest(gerritCluster.build());
+
+    AdmissionReview response2 =
+        new ObjectMapper().readValue(http2.getInputStream(), AdmissionReview.class);
+
+    assertThat(http2.getResponseCode(), is(equalTo(HttpServletResponse.SC_OK)));
+    assertThat(response2.getResponse().getAllowed(), is(false));
+    assertThat(
+        response2.getResponse().getStatus().getCode(),
+        is(equalTo(HttpServletResponse.SC_CONFLICT)));
+  }
+
+  @Test
+  public void testPrimaryAndReplicaAreAcceptedInSameGerritCluster() throws Exception {
+    Config cfg = new Config();
+    cfg.fromText(TestGerrit.DEFAULT_GERRIT_CONFIG);
+    GerritTemplate gerrit1 = TestGerrit.createGerritTemplate("gerrit1", GerritMode.PRIMARY, cfg);
+    TestGerritCluster gerritCluster =
+        new TestGerritCluster(kubernetesServer.getClient(), NAMESPACE);
+    gerritCluster.addGerrit(gerrit1);
+
+    HttpURLConnection http = sendAdmissionRequest(gerritCluster.build());
+
+    AdmissionReview response =
+        new ObjectMapper().readValue(http.getInputStream(), AdmissionReview.class);
+
+    assertThat(http.getResponseCode(), is(equalTo(HttpServletResponse.SC_OK)));
+    assertThat(response.getResponse().getAllowed(), is(true));
+
+    GerritTemplate gerrit2 = TestGerrit.createGerritTemplate("gerrit2", GerritMode.REPLICA, cfg);
+    gerritCluster.addGerrit(gerrit2);
+    HttpURLConnection http2 = sendAdmissionRequest(gerritCluster.build());
+
+    AdmissionReview response2 =
+        new ObjectMapper().readValue(http2.getInputStream(), AdmissionReview.class);
+
+    assertThat(http.getResponseCode(), is(equalTo(HttpServletResponse.SC_OK)));
+    assertThat(response2.getResponse().getAllowed(), is(true));
+  }
+
+  private HttpURLConnection sendAdmissionRequest(GerritCluster gerritCluster)
+      throws MalformedURLException, IOException {
+    HttpURLConnection http =
+        (HttpURLConnection)
+            new URL("http://localhost:8080/admission/v1alpha/gerritcluster").openConnection();
+    http.setRequestMethod(HttpMethod.POST.asString());
+    http.setRequestProperty("Content-Type", "application/json");
+    http.setDoOutput(true);
+
+    AdmissionRequest admissionReq = new AdmissionRequest();
+    admissionReq.setObject(gerritCluster);
+    AdmissionReview admissionReview = new AdmissionReview();
+    admissionReview.setRequest(admissionReq);
+
+    try (OutputStream os = http.getOutputStream()) {
+      byte[] input = new ObjectMapper().writer().writeValueAsBytes(admissionReview);
+      os.write(input, 0, input.length);
+    }
+    return http;
+  }
+
+  @AfterAll
+  public void shutdown() throws Exception {
+    server.stop();
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/server/GitGcAdmissionWebhookTest.java b/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/server/GitGcAdmissionWebhookTest.java
new file mode 100644
index 0000000..5553e95
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/server/GitGcAdmissionWebhookTest.java
@@ -0,0 +1,256 @@
+// Copyright (C) 2023 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.server;
+
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.equalTo;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.google.gerrit.k8s.operator.test.TestAdmissionWebhookServer;
+import com.google.gerrit.k8s.operator.v1alpha.admission.servlet.GitGcAdmissionWebhook;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.cluster.GerritCluster;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.gitgc.GitGarbageCollection;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.gitgc.GitGarbageCollectionSpec;
+import io.fabric8.kubernetes.api.model.DefaultKubernetesResourceList;
+import io.fabric8.kubernetes.api.model.HasMetadata;
+import io.fabric8.kubernetes.api.model.ObjectMetaBuilder;
+import io.fabric8.kubernetes.api.model.admission.v1.AdmissionRequest;
+import io.fabric8.kubernetes.api.model.admission.v1.AdmissionReview;
+import io.fabric8.kubernetes.client.server.mock.KubernetesServer;
+import io.fabric8.kubernetes.internal.KubernetesDeserializer;
+import jakarta.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.HttpURLConnection;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.util.List;
+import java.util.Set;
+import org.apache.commons.lang3.RandomStringUtils;
+import org.eclipse.jetty.http.HttpMethod;
+import org.junit.Rule;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInstance;
+import org.junit.jupiter.api.TestInstance.Lifecycle;
+
+@TestInstance(Lifecycle.PER_CLASS)
+public class GitGcAdmissionWebhookTest {
+  private static final String NAMESPACE = "test";
+  private static final String LIST_GITGCS_PATH =
+      String.format(
+          "/apis/%s/namespaces/%s/%s",
+          HasMetadata.getApiVersion(GitGarbageCollection.class),
+          NAMESPACE,
+          HasMetadata.getPlural(GitGarbageCollection.class));
+  private TestAdmissionWebhookServer server;
+
+  @Rule public KubernetesServer kubernetesServer = new KubernetesServer();
+
+  @BeforeAll
+  public void setup() throws Exception {
+    KubernetesDeserializer.registerCustomKind(
+        "gerritoperator.google.com/v1alpha16", "GerritCluster", GerritCluster.class);
+    KubernetesDeserializer.registerCustomKind(
+        "gerritoperator.google.com/v1alpha1", "GitGarbageCollection", GitGarbageCollection.class);
+    server = new TestAdmissionWebhookServer();
+
+    kubernetesServer.before();
+
+    GitGcAdmissionWebhook webhook = new GitGcAdmissionWebhook(kubernetesServer.getClient());
+    server.registerWebhook(webhook);
+    server.start();
+  }
+
+  @Test
+  @DisplayName("Only a single GitGC that works on all projects in site is allowed.")
+  public void testOnlySingleGitGcWorkingOnAllProjectsIsAllowed() throws Exception {
+    GitGarbageCollection gitGc = createCompleteGitGc();
+    kubernetesServer
+        .expect()
+        .get()
+        .withPath(LIST_GITGCS_PATH)
+        .andReturn(
+            HttpURLConnection.HTTP_OK, new DefaultKubernetesResourceList<GitGarbageCollection>())
+        .once();
+
+    HttpURLConnection http = sendAdmissionRequest(gitGc);
+
+    AdmissionReview response =
+        new ObjectMapper().readValue(http.getInputStream(), AdmissionReview.class);
+
+    assertThat(http.getResponseCode(), is(equalTo(HttpServletResponse.SC_OK)));
+    assertThat(response.getResponse().getAllowed(), is(true));
+
+    DefaultKubernetesResourceList<GitGarbageCollection> existingGitGcs =
+        new DefaultKubernetesResourceList<GitGarbageCollection>();
+    existingGitGcs.setItems(List.of(createCompleteGitGc()));
+    kubernetesServer
+        .expect()
+        .get()
+        .withPath(LIST_GITGCS_PATH)
+        .andReturn(HttpURLConnection.HTTP_OK, existingGitGcs)
+        .once();
+
+    HttpURLConnection http2 = sendAdmissionRequest(gitGc);
+
+    AdmissionReview response2 =
+        new ObjectMapper().readValue(http2.getInputStream(), AdmissionReview.class);
+
+    assertThat(http2.getResponseCode(), is(equalTo(HttpServletResponse.SC_OK)));
+    assertThat(response2.getResponse().getAllowed(), is(false));
+    assertThat(
+        response2.getResponse().getStatus().getCode(),
+        is(equalTo(HttpServletResponse.SC_CONFLICT)));
+  }
+
+  @Test
+  @DisplayName(
+      "A GitGc configured to work on all projects and selective GitGcs are allowed to exist at the same time.")
+  public void testSelectiveAndCompleteGitGcAreAllowedTogether() throws Exception {
+    DefaultKubernetesResourceList<GitGarbageCollection> existingGitGcs =
+        new DefaultKubernetesResourceList<GitGarbageCollection>();
+    existingGitGcs.setItems(List.of(createCompleteGitGc()));
+    kubernetesServer
+        .expect()
+        .get()
+        .withPath(LIST_GITGCS_PATH)
+        .andReturn(HttpURLConnection.HTTP_OK, existingGitGcs)
+        .once();
+
+    GitGarbageCollection gitGc2 = createGitGcForProjects(Set.of("project3"));
+    HttpURLConnection http2 = sendAdmissionRequest(gitGc2);
+
+    AdmissionReview response2 =
+        new ObjectMapper().readValue(http2.getInputStream(), AdmissionReview.class);
+
+    assertThat(http2.getResponseCode(), is(equalTo(HttpServletResponse.SC_OK)));
+    assertThat(response2.getResponse().getAllowed(), is(true));
+  }
+
+  @Test
+  @DisplayName("Multiple selectve GitGcs working on a different set of projects are allowed.")
+  public void testNonConflictingSelectiveGcsAreAllowed() throws Exception {
+    GitGarbageCollection gitGc = createGitGcForProjects(Set.of("project1", "project2"));
+    DefaultKubernetesResourceList<GitGarbageCollection> existingGitGcs =
+        new DefaultKubernetesResourceList<GitGarbageCollection>();
+    existingGitGcs.setItems(List.of(gitGc));
+    kubernetesServer
+        .expect()
+        .get()
+        .withPath(LIST_GITGCS_PATH)
+        .andReturn(HttpURLConnection.HTTP_OK, existingGitGcs)
+        .once();
+
+    GitGarbageCollection gitGc2 = createGitGcForProjects(Set.of("project3"));
+    HttpURLConnection http2 = sendAdmissionRequest(gitGc2);
+
+    AdmissionReview response2 =
+        new ObjectMapper().readValue(http2.getInputStream(), AdmissionReview.class);
+
+    assertThat(http2.getResponseCode(), is(equalTo(HttpServletResponse.SC_OK)));
+    assertThat(response2.getResponse().getAllowed(), is(true));
+  }
+
+  @Test
+  @DisplayName("Multiple selectve GitGcs working on the same project(s) are not allowed.")
+  public void testConflictingSelectiveGcsNotAllowed() throws Exception {
+    GitGarbageCollection gitGc = createGitGcForProjects(Set.of("project1", "project2"));
+    kubernetesServer
+        .expect()
+        .get()
+        .withPath(LIST_GITGCS_PATH)
+        .andReturn(
+            HttpURLConnection.HTTP_OK, new DefaultKubernetesResourceList<GitGarbageCollection>())
+        .once();
+
+    HttpURLConnection http = sendAdmissionRequest(gitGc);
+
+    AdmissionReview response =
+        new ObjectMapper().readValue(http.getInputStream(), AdmissionReview.class);
+
+    assertThat(http.getResponseCode(), is(equalTo(HttpServletResponse.SC_OK)));
+    assertThat(response.getResponse().getAllowed(), is(true));
+
+    DefaultKubernetesResourceList<GitGarbageCollection> existingGitGcs =
+        new DefaultKubernetesResourceList<GitGarbageCollection>();
+    existingGitGcs.setItems(List.of(gitGc));
+    kubernetesServer
+        .expect()
+        .get()
+        .withPath(LIST_GITGCS_PATH)
+        .andReturn(HttpURLConnection.HTTP_OK, existingGitGcs)
+        .once();
+
+    GitGarbageCollection gitGc2 = createGitGcForProjects(Set.of("project1"));
+    HttpURLConnection http2 = sendAdmissionRequest(gitGc2);
+
+    AdmissionReview response2 =
+        new ObjectMapper().readValue(http2.getInputStream(), AdmissionReview.class);
+
+    assertThat(http2.getResponseCode(), is(equalTo(HttpServletResponse.SC_OK)));
+    assertThat(response2.getResponse().getAllowed(), is(false));
+    assertThat(
+        response2.getResponse().getStatus().getCode(),
+        is(equalTo(HttpServletResponse.SC_CONFLICT)));
+  }
+
+  private GitGarbageCollection createCompleteGitGc() {
+    return createGitGcForProjects(Set.of());
+  }
+
+  private GitGarbageCollection createGitGcForProjects(Set<String> projects) {
+    GitGarbageCollectionSpec spec = new GitGarbageCollectionSpec();
+    spec.setProjects(projects);
+    GitGarbageCollection gitGc = new GitGarbageCollection();
+    gitGc.setMetadata(
+        new ObjectMetaBuilder()
+            .withName(RandomStringUtils.randomAlphabetic(10))
+            .withUid(RandomStringUtils.randomAlphabetic(10))
+            .withNamespace(NAMESPACE)
+            .build());
+    gitGc.setSpec(spec);
+    return gitGc;
+  }
+
+  private HttpURLConnection sendAdmissionRequest(GitGarbageCollection gitGc)
+      throws MalformedURLException, IOException {
+    HttpURLConnection http =
+        (HttpURLConnection)
+            new URL("http://localhost:8080/admission/v1alpha/gitgc").openConnection();
+    http.setRequestMethod(HttpMethod.POST.asString());
+    http.setRequestProperty("Content-Type", "application/json");
+    http.setDoOutput(true);
+
+    AdmissionRequest admissionReq = new AdmissionRequest();
+    admissionReq.setObject(gitGc);
+    AdmissionReview admissionReview = new AdmissionReview();
+    admissionReview.setRequest(admissionReq);
+
+    try (OutputStream os = http.getOutputStream()) {
+      byte[] input = new ObjectMapper().writer().writeValueAsBytes(admissionReview);
+      os.write(input, 0, input.length);
+    }
+    return http;
+  }
+
+  @AfterAll
+  public void shutdown() throws Exception {
+    server.stop();
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/test/AbstractGerritOperatorE2ETest.java b/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/test/AbstractGerritOperatorE2ETest.java
new file mode 100644
index 0000000..ee1aa01
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/test/AbstractGerritOperatorE2ETest.java
@@ -0,0 +1,140 @@
+// 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 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.network.GerritNetworkReconcilerProvider;
+import com.google.gerrit.k8s.operator.network.IngressType;
+import com.google.gerrit.k8s.operator.receiver.ReceiverReconciler;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.cluster.GerritCluster;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.gerrit.Gerrit;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.gitgc.GitGarbageCollection;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.network.GerritNetwork;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.receiver.Receiver;
+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.KubernetesClient;
+import io.fabric8.kubernetes.client.KubernetesClientBuilder;
+import io.javaoperatorsdk.operator.api.reconciler.Reconciler;
+import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Base64;
+import java.util.Map;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.extension.RegisterExtension;
+import org.mockito.Mockito;
+
+public abstract class AbstractGerritOperatorE2ETest {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+  protected static final KubernetesClient client = getKubernetesClient();
+  public static final String IMAGE_PULL_SECRET_NAME = "image-pull-secret";
+  public static final TestProperties testProps = new TestProperties();
+
+  protected GerritReconciler gerritReconciler = Mockito.spy(new GerritReconciler(client));
+  protected TestGerritCluster gerritCluster;
+  protected TestSecureConfig secureConfig;
+  protected Secret receiverCredentials;
+
+  @RegisterExtension
+  protected LocallyRunOperatorExtension operator =
+      LocallyRunOperatorExtension.builder()
+          .withNamespaceDeleteTimeout(120)
+          .waitForNamespaceDeletion(true)
+          .withReconciler(new GerritClusterReconciler())
+          .withReconciler(gerritReconciler)
+          .withReconciler(new GitGarbageCollectionReconciler(client))
+          .withReconciler(new ReceiverReconciler(client))
+          .withReconciler(getGerritNetworkReconciler())
+          .build();
+
+  @BeforeEach
+  void setup() {
+    Mockito.reset(gerritReconciler);
+    createImagePullSecret(client, operator.getNamespace());
+
+    secureConfig = new TestSecureConfig(client, testProps, operator.getNamespace());
+    secureConfig.createOrReplace();
+
+    receiverCredentials = ReceiverUtil.createCredentialsSecret(operator.getNamespace());
+
+    client.resource(receiverCredentials).inNamespace(operator.getNamespace()).createOrReplace();
+
+    gerritCluster = new TestGerritCluster(client, operator.getNamespace());
+    gerritCluster.setIngressType(getIngressType());
+    gerritCluster.deploy();
+  }
+
+  @AfterEach
+  void cleanup() {
+    client.resources(Gerrit.class).inNamespace(operator.getNamespace()).delete();
+    client.resources(Receiver.class).inNamespace(operator.getNamespace()).delete();
+    client.resources(GitGarbageCollection.class).inNamespace(operator.getNamespace()).delete();
+    client.resources(GerritCluster.class).inNamespace(operator.getNamespace()).delete();
+    client.resource(receiverCredentials).inNamespace(operator.getNamespace()).delete();
+  }
+
+  private static KubernetesClient getKubernetesClient() {
+    Config config;
+    try {
+      String kubeconfig = System.getenv("KUBECONFIG");
+      if (kubeconfig != null) {
+        config = Config.fromKubeconfig(Files.readString(Path.of(kubeconfig)));
+        return new KubernetesClientBuilder().withConfig(config).build();
+      }
+      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 KubernetesClientBuilder().build();
+  }
+
+  private static void createImagePullSecret(KubernetesClient client, String namespace) {
+    StringBuilder secretBuilder = new StringBuilder();
+    secretBuilder.append("{\"auths\": {\"");
+    secretBuilder.append(testProps.getRegistry());
+    secretBuilder.append("\": {\"auth\": \"");
+    secretBuilder.append(
+        Base64.getEncoder()
+            .encodeToString(
+                String.format("%s:%s", testProps.getRegistryUser(), testProps.getRegistryPwd())
+                    .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.resource(imagePullSecret).createOrReplace();
+  }
+
+  public Reconciler<GerritNetwork> getGerritNetworkReconciler() {
+    return new GerritNetworkReconcilerProvider(getIngressType()).get();
+  }
+
+  protected abstract IngressType getIngressType();
+}
diff --git a/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/test/ReceiverUtil.java b/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/test/ReceiverUtil.java
new file mode 100644
index 0000000..b6cdc5a
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/test/ReceiverUtil.java
@@ -0,0 +1,75 @@
+// 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.gerrit.k8s.operator.v1alpha.api.model.cluster.GerritCluster;
+import io.fabric8.kubernetes.api.model.Secret;
+import io.fabric8.kubernetes.api.model.SecretBuilder;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.Map;
+import org.apache.commons.codec.digest.Md5Crypt;
+import org.apache.commons.lang3.RandomStringUtils;
+import org.apache.http.client.utils.URIBuilder;
+
+public class ReceiverUtil {
+  public static final String RECEIVER_TEST_USER = "git";
+  public static final String RECEIVER_TEST_PASSWORD = RandomStringUtils.randomAlphanumeric(32);
+  public static final String CREDENTIALS_SECRET_NAME = "receiver-secret";
+  public static final TestProperties testProps = new TestProperties();
+
+  public static int sendReceiverApiRequest(GerritCluster gerritCluster, String method, String path)
+      throws Exception {
+    URL url = getReceiverUrl(gerritCluster, path);
+
+    HttpURLConnection con = (HttpURLConnection) url.openConnection();
+    try {
+      con.setRequestMethod(method);
+      String encodedAuth =
+          Base64.getEncoder()
+              .encodeToString(
+                  String.format("%s:%s", RECEIVER_TEST_USER, RECEIVER_TEST_PASSWORD)
+                      .getBytes(StandardCharsets.UTF_8));
+      con.setRequestProperty("Authorization", "Basic " + encodedAuth);
+      return con.getResponseCode();
+    } finally {
+      con.disconnect();
+    }
+  }
+
+  public static URL getReceiverUrl(GerritCluster gerritCluster, String path) throws Exception {
+    return new URIBuilder()
+        .setScheme("https")
+        .setHost(gerritCluster.getSpec().getIngress().getHost())
+        .setPath(path)
+        .build()
+        .toURL();
+  }
+
+  public static Secret createCredentialsSecret(String namespace) {
+    String enPasswd = Md5Crypt.md5Crypt(RECEIVER_TEST_PASSWORD.getBytes());
+    String htpasswdContent = RECEIVER_TEST_USER + ":" + enPasswd;
+    return new SecretBuilder()
+        .withNewMetadata()
+        .withNamespace(namespace)
+        .withName(CREDENTIALS_SECRET_NAME)
+        .endMetadata()
+        .withData(
+            Map.of(".htpasswd", Base64.getEncoder().encodeToString(htpasswdContent.getBytes())))
+        .build();
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/test/TestAdmissionWebhookServer.java b/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/test/TestAdmissionWebhookServer.java
new file mode 100644
index 0000000..90c55be
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/test/TestAdmissionWebhookServer.java
@@ -0,0 +1,52 @@
+// Copyright (C) 2023 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.gerrit.k8s.operator.server.AdmissionWebhookServlet;
+import org.eclipse.jetty.server.Connector;
+import org.eclipse.jetty.server.HttpConnectionFactory;
+import org.eclipse.jetty.server.Server;
+import org.eclipse.jetty.server.ServerConnector;
+import org.eclipse.jetty.servlet.ServletHandler;
+import org.eclipse.jetty.servlet.ServletHolder;
+
+public class TestAdmissionWebhookServer {
+  public static final String KEYSTORE_PATH = "/operator/keystore.jks";
+  public static final String KEYSTORE_PWD_FILE = "/operator/keystore.password";
+  public static final int PORT = 8080;
+
+  private final Server server = new Server();
+  private final ServletHandler servletHandler = new ServletHandler();
+
+  public void start() throws Exception {
+    HttpConnectionFactory httpConnectionFactory = new HttpConnectionFactory();
+
+    ServerConnector connector = new ServerConnector(server, httpConnectionFactory);
+    connector.setPort(PORT);
+    server.setConnectors(new Connector[] {connector});
+    server.setHandler(servletHandler);
+
+    server.start();
+  }
+
+  public void registerWebhook(AdmissionWebhookServlet webhook) {
+    servletHandler.addServletWithMapping(
+        new ServletHolder(webhook), "/admission/" + webhook.getVersion() + "/" + webhook.getName());
+  }
+
+  public void stop() throws Exception {
+    server.stop();
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/test/TestGerrit.java b/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/test/TestGerrit.java
new file mode 100644
index 0000000..5762ea5
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/test/TestGerrit.java
@@ -0,0 +1,284 @@
+// 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.TestSecureConfig.SECURE_CONFIG_SECRET_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.k8s.operator.gerrit.dependent.GerritConfigMap;
+import com.google.gerrit.k8s.operator.gerrit.dependent.GerritInitConfigMap;
+import com.google.gerrit.k8s.operator.gerrit.dependent.GerritService;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.gerrit.Gerrit;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.gerrit.GerritSite;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.gerrit.GerritSpec;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.gerrit.GerritTemplate;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.gerrit.GerritTemplateSpec;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.gerrit.GerritTemplateSpec.GerritMode;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.shared.ContainerImageConfig;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.shared.GerritRepositoryConfig;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.shared.GerritStorageConfig;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.shared.IngressConfig;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.shared.SharedStorage;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.shared.StorageClassConfig;
+import io.fabric8.kubernetes.api.model.LocalObjectReference;
+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 java.util.HashSet;
+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 TestProperties testProps = new TestProperties();
+  public static final String DEFAULT_GERRIT_CONFIG =
+      "[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"
+          + "  requestLog = true\n"
+          + "  gracefulStopTimeout = 1m\n"
+          + "[transfer]\n"
+          + "  timeout = 120 s\n"
+          + "[user]\n"
+          + "  name = Gerrit Code Review\n"
+          + "  email = gerrit@example.com\n"
+          + "  anonymousCoward = Unnamed User\n"
+          + "[container]\n"
+          + "  javaOptions = -Xmx4g";
+
+  private final KubernetesClient client;
+  private final String name;
+  private final String namespace;
+  private final GerritMode mode;
+
+  private Gerrit gerrit = new Gerrit();
+  private Config config = defaultConfig();
+
+  public TestGerrit(
+      KubernetesClient client,
+      TestProperties testProps,
+      GerritMode mode,
+      String name,
+      String namespace) {
+    this.client = client;
+    this.mode = mode;
+    this.name = name;
+    this.namespace = namespace;
+  }
+
+  public TestGerrit(
+      KubernetesClient client, TestProperties testProps, String name, String namespace) {
+    this(client, testProps, GerritMode.PRIMARY, name, namespace);
+  }
+
+  public void build() {
+    createGerritCR();
+  }
+
+  public void deploy() {
+    build();
+    client.resource(gerrit).inNamespace(namespace).createOrReplace();
+    waitForGerritReadiness();
+  }
+
+  public void modifyGerritConfig(String section, String key, String value) {
+    config.setString(section, null, key, value);
+  }
+
+  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;
+  }
+
+  public GerritTemplate createGerritTemplate() throws ConfigInvalidException {
+    return createGerritTemplate(name, mode, config);
+  }
+
+  public static GerritTemplate createGerritTemplate(String name, GerritMode mode)
+      throws ConfigInvalidException {
+    Config cfg = new Config();
+    cfg.fromText(DEFAULT_GERRIT_CONFIG);
+    return createGerritTemplate(name, mode, cfg);
+  }
+
+  public static GerritTemplate createGerritTemplate(String name, GerritMode mode, Config config) {
+    GerritTemplate template = new GerritTemplate();
+    ObjectMeta gerritMeta = new ObjectMetaBuilder().withName(name).build();
+    template.setMetadata(gerritMeta);
+    GerritTemplateSpec gerritSpec = template.getSpec();
+    if (gerritSpec == null) {
+      gerritSpec = new GerritTemplateSpec();
+      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.setMode(mode);
+    gerritSpec.setConfigFiles(Map.of("gerrit.config", config.toText()));
+    gerritSpec.setSecretRef(SECURE_CONFIG_SECRET_NAME);
+    template.setSpec(gerritSpec);
+    return template;
+  }
+
+  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.setServerId("gerrit-1234");
+      gerritSpec.setResources(
+          new ResourceRequirementsBuilder()
+              .withRequests(Map.of("cpu", new Quantity("1"), "memory", new Quantity("5Gi")))
+              .build());
+    }
+    gerritSpec.setMode(mode);
+    gerritSpec.setConfigFiles(Map.of("gerrit.config", config.toText()));
+    gerritSpec.setSecretRef(SECURE_CONFIG_SECRET_NAME);
+
+    SharedStorage sharedStorage = new SharedStorage();
+    sharedStorage.setSize(Quantity.parse("1Gi"));
+
+    StorageClassConfig storageClassConfig = new StorageClassConfig();
+    storageClassConfig.setReadWriteMany(testProps.getRWMStorageClass());
+
+    GerritStorageConfig gerritStorageConfig = new GerritStorageConfig();
+    gerritStorageConfig.setSharedStorage(sharedStorage);
+    gerritStorageConfig.setStorageClasses(storageClassConfig);
+    gerritSpec.setStorage(gerritStorageConfig);
+
+    GerritRepositoryConfig repoConfig = new GerritRepositoryConfig();
+    repoConfig.setOrg(testProps.getRegistryOrg());
+    repoConfig.setRegistry(testProps.getRegistry());
+    repoConfig.setTag(testProps.getTag());
+
+    ContainerImageConfig containerImageConfig = new ContainerImageConfig();
+    containerImageConfig.setGerritImages(repoConfig);
+    Set<LocalObjectReference> imagePullSecrets = new HashSet<>();
+    imagePullSecrets.add(
+        new LocalObjectReference(AbstractGerritOperatorE2ETest.IMAGE_PULL_SECRET_NAME));
+    containerImageConfig.setImagePullSecrets(imagePullSecrets);
+    gerritSpec.setContainerImages(containerImageConfig);
+
+    IngressConfig ingressConfig = new IngressConfig();
+    ingressConfig.setHost(testProps.getIngressDomain());
+    ingressConfig.setTlsEnabled(false);
+    gerritSpec.setIngress(ingressConfig);
+
+    gerrit.setSpec(gerritSpec);
+  }
+
+  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(GerritConfigMap.getName(gerrit))
+                      .get(),
+                  is(notNullValue()));
+              assertThat(
+                  client
+                      .configMaps()
+                      .inNamespace(namespace)
+                      .withName(GerritInitConfigMap.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(GerritService.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/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/test/TestGerritCluster.java b/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/test/TestGerritCluster.java
new file mode 100644
index 0000000..ce66ec2
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/test/TestGerritCluster.java
@@ -0,0 +1,256 @@
+// 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 java.util.concurrent.TimeUnit.SECONDS;
+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.network.IngressType;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.cluster.GerritCluster;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.cluster.GerritClusterSpec;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.gerrit.GerritTemplate;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.receiver.ReceiverTemplate;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.shared.ContainerImageConfig;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.shared.GerritClusterIngressConfig;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.shared.GerritIngressTlsConfig;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.shared.GerritRepositoryConfig;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.shared.GerritStorageConfig;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.shared.NfsWorkaroundConfig;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.shared.SharedStorage;
+import com.google.gerrit.k8s.operator.v1alpha.api.model.shared.StorageClassConfig;
+import com.urswolfer.gerrit.client.rest.GerritAuthData;
+import com.urswolfer.gerrit.client.rest.GerritRestApiFactory;
+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.client.KubernetesClient;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+
+public class TestGerritCluster {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+  public static final String CLUSTER_NAME = "test-cluster";
+  public static final TestProperties testProps = new TestProperties();
+
+  private final KubernetesClient client;
+  private final String namespace;
+
+  private GerritClusterIngressConfig ingressConfig;
+  private boolean isNfsEnabled = false;
+  private GerritCluster cluster = new GerritCluster();
+  private String hostname;
+  private List<GerritTemplate> gerrits = new ArrayList<>();
+  private Optional<ReceiverTemplate> receiver = Optional.empty();
+
+  public TestGerritCluster(KubernetesClient client, String namespace) {
+    this.client = client;
+    this.namespace = namespace;
+
+    defaultIngressConfig();
+  }
+
+  public GerritCluster getGerritCluster() {
+    return cluster;
+  }
+
+  public String getHostname() {
+    return hostname;
+  }
+
+  public String getNamespace() {
+    return cluster.getMetadata().getNamespace();
+  }
+
+  public void setIngressType(IngressType type) {
+    switch (type) {
+      case INGRESS:
+        hostname = testProps.getIngressDomain();
+        enableIngress();
+        break;
+      case ISTIO:
+        hostname = testProps.getIstioDomain();
+        enableIngress();
+        break;
+      default:
+        hostname = null;
+        defaultIngressConfig();
+    }
+    deploy();
+  }
+
+  private void defaultIngressConfig() {
+    ingressConfig = new GerritClusterIngressConfig();
+    ingressConfig.setEnabled(false);
+  }
+
+  private void enableIngress() {
+    ingressConfig = new GerritClusterIngressConfig();
+    ingressConfig.setEnabled(true);
+    ingressConfig.setHost(hostname);
+    GerritIngressTlsConfig ingressTlsConfig = new GerritIngressTlsConfig();
+    ingressTlsConfig.setEnabled(true);
+    ingressTlsConfig.setSecret("tls-secret");
+    ingressConfig.setTls(ingressTlsConfig);
+  }
+
+  public GerritApi getGerritApiClient(GerritTemplate gerrit, IngressType ingressType) {
+    return new GerritRestApiFactory()
+        .create(new GerritAuthData.Basic(String.format("https://%s", hostname)));
+  }
+
+  public void setNfsEnabled(boolean isNfsEnabled) {
+    this.isNfsEnabled = isNfsEnabled;
+    deploy();
+  }
+
+  public void addGerrit(GerritTemplate gerrit) {
+    gerrits.add(gerrit);
+  }
+
+  public void removeGerrit(GerritTemplate gerrit) {
+    gerrits.remove(gerrit);
+  }
+
+  public void setReceiver(ReceiverTemplate receiver) {
+    this.receiver = Optional.ofNullable(receiver);
+  }
+
+  public GerritCluster build() {
+    cluster.setMetadata(
+        new ObjectMetaBuilder().withName(CLUSTER_NAME).withNamespace(namespace).build());
+
+    SharedStorage sharedStorage = new SharedStorage();
+    sharedStorage.setSize(Quantity.parse("1Gi"));
+
+    StorageClassConfig storageClassConfig = new StorageClassConfig();
+    storageClassConfig.setReadWriteMany(testProps.getRWMStorageClass());
+
+    NfsWorkaroundConfig nfsWorkaround = new NfsWorkaroundConfig();
+    nfsWorkaround.setEnabled(isNfsEnabled);
+    nfsWorkaround.setIdmapdConfig("[General]\nDomain = localdomain.com");
+    storageClassConfig.setNfsWorkaround(nfsWorkaround);
+
+    GerritClusterSpec clusterSpec = new GerritClusterSpec();
+    GerritStorageConfig gerritStorageConfig = new GerritStorageConfig();
+    gerritStorageConfig.setSharedStorage(sharedStorage);
+    gerritStorageConfig.setStorageClasses(storageClassConfig);
+    clusterSpec.setStorage(gerritStorageConfig);
+
+    GerritRepositoryConfig repoConfig = new GerritRepositoryConfig();
+    repoConfig.setOrg(testProps.getRegistryOrg());
+    repoConfig.setRegistry(testProps.getRegistry());
+    repoConfig.setTag(testProps.getTag());
+
+    ContainerImageConfig containerImageConfig = new ContainerImageConfig();
+    containerImageConfig.setGerritImages(repoConfig);
+    Set<LocalObjectReference> imagePullSecrets = new HashSet<>();
+    imagePullSecrets.add(
+        new LocalObjectReference(AbstractGerritOperatorE2ETest.IMAGE_PULL_SECRET_NAME));
+    containerImageConfig.setImagePullSecrets(imagePullSecrets);
+    clusterSpec.setContainerImages(containerImageConfig);
+
+    clusterSpec.setIngress(ingressConfig);
+
+    clusterSpec.setGerrits(gerrits);
+    if (receiver.isPresent()) {
+      clusterSpec.setReceiver(receiver.get());
+    }
+
+    cluster.setSpec(clusterSpec);
+    return cluster;
+  }
+
+  public void deploy() {
+    build();
+    client.resource(cluster).inNamespace(namespace).createOrReplace();
+    await()
+        .atMost(2, MINUTES)
+        .untilAsserted(
+            () -> {
+              assertThat(
+                  client
+                      .resources(GerritCluster.class)
+                      .inNamespace(namespace)
+                      .withName(CLUSTER_NAME)
+                      .get(),
+                  is(notNullValue()));
+            });
+
+    GerritCluster updatedCluster =
+        client.resources(GerritCluster.class).inNamespace(namespace).withName(CLUSTER_NAME).get();
+    for (GerritTemplate gerrit : updatedCluster.getSpec().getGerrits()) {
+      waitForGerritReadiness(gerrit);
+    }
+    if (receiver.isPresent()) {
+      waitForReceiverReadiness();
+    }
+  }
+
+  private void waitForGerritReadiness(GerritTemplate gerrit) {
+    logger.atInfo().log("Waiting max 2 minutes for the Gerrit StatefulSet to be ready.");
+    await()
+        .pollDelay(15, SECONDS)
+        .atMost(2, MINUTES)
+        .untilAsserted(
+            () -> {
+              assertThat(
+                  client
+                      .apps()
+                      .statefulSets()
+                      .inNamespace(namespace)
+                      .withName(gerrit.getMetadata().getName())
+                      .get(),
+                  is(notNullValue()));
+              assertTrue(
+                  client
+                      .apps()
+                      .statefulSets()
+                      .inNamespace(namespace)
+                      .withName(gerrit.getMetadata().getName())
+                      .isReady());
+              assertTrue(
+                  client
+                      .pods()
+                      .inNamespace(namespace)
+                      .withName(gerrit.getMetadata().getName() + "-0")
+                      .isReady());
+            });
+  }
+
+  private void waitForReceiverReadiness() {
+    await()
+        .atMost(2, MINUTES)
+        .untilAsserted(
+            () -> {
+              assertTrue(
+                  client
+                      .apps()
+                      .deployments()
+                      .inNamespace(namespace)
+                      .withName(receiver.get().getMetadata().getName())
+                      .isReady());
+            });
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/test/TestProperties.java b/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/test/TestProperties.java
new file mode 100644
index 0000000..d7d23bf
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/test/TestProperties.java
@@ -0,0 +1,78 @@
+// 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 java.io.FileInputStream;
+import java.io.IOException;
+import java.util.Properties;
+
+public class TestProperties {
+  private final Properties props = getProperties();
+
+  private static Properties getProperties() {
+    String propertiesPath = System.getProperty("properties", "test.properties");
+    Properties props = new Properties();
+    try {
+      props.load(new FileInputStream(propertiesPath));
+    } catch (IOException e) {
+      throw new IllegalStateException("Could not load properties file.");
+    }
+    return props;
+  }
+
+  public String getRWMStorageClass() {
+    return props.getProperty("rwmStorageClass", "nfs-client");
+  }
+
+  public String getRegistry() {
+    return props.getProperty("registry", "");
+  }
+
+  public String getRegistryOrg() {
+    return props.getProperty("registryOrg", "k8sgerrit");
+  }
+
+  public String getRegistryUser() {
+    return props.getProperty("registryUser", "");
+  }
+
+  public String getRegistryPwd() {
+    return props.getProperty("registryPwd", "");
+  }
+
+  public String getTag() {
+    return props.getProperty("tag", "");
+  }
+
+  public String getIngressDomain() {
+    return props.getProperty("ingressDomain", "");
+  }
+
+  public String getIstioDomain() {
+    return props.getProperty("istioDomain", "");
+  }
+
+  public String getLdapAdminPwd() {
+    return props.getProperty("ldapAdminPwd", "");
+  }
+
+  public String getGerritUser() {
+    return props.getProperty("gerritUser", "");
+  }
+
+  public String getGerritPwd() {
+    return props.getProperty("gerritPwd", "");
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/test/TestSecureConfig.java b/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/test/TestSecureConfig.java
new file mode 100644
index 0000000..04688d7
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/test/java/com/google/gerrit/k8s/operator/test/TestSecureConfig.java
@@ -0,0 +1,58 @@
+// Copyright (C) 2023 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 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 org.eclipse.jgit.lib.Config;
+
+public class TestSecureConfig {
+  public static final String SECURE_CONFIG_SECRET_NAME = "gerrit-secret";
+
+  private final KubernetesClient client;
+  private final String namespace;
+
+  private Config secureConfig = new Config();
+  private Secret secureConfigSecret;
+
+  public TestSecureConfig(KubernetesClient client, TestProperties testProps, String namespace) {
+    this.client = client;
+    this.namespace = namespace;
+    this.secureConfig.setString("ldap", null, "password", testProps.getLdapAdminPwd());
+  }
+
+  public void createOrReplace() {
+    secureConfigSecret =
+        new SecretBuilder()
+            .withNewMetadata()
+            .withNamespace(namespace)
+            .withName(SECURE_CONFIG_SECRET_NAME)
+            .endMetadata()
+            .withData(
+                Map.of(
+                    "secure.config",
+                    Base64.getEncoder().encodeToString(secureConfig.toText().getBytes())))
+            .build();
+    client.resource(secureConfigSecret).inNamespace(namespace).createOrReplace();
+  }
+
+  public void modify(String section, String key, String value) {
+    secureConfig.setString(section, null, key, value);
+    createOrReplace();
+  }
+}
diff --git a/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/ambassador/dependent/host.yaml b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/ambassador/dependent/host.yaml
new file mode 100644
index 0000000..59ff0a4
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/ambassador/dependent/host.yaml
@@ -0,0 +1,9 @@
+apiVersion: getambassador.io/v2
+kind: Host
+metadata:
+  name: gerrit-ambassador-host
+spec:
+  ambassador_id:
+    - my-id-1
+    - my-id-2
+  hostname: example.com
\ No newline at end of file
diff --git a/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/ambassador/dependent/host_with_tls.yaml b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/ambassador/dependent/host_with_tls.yaml
new file mode 100644
index 0000000..c386b3a
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/ambassador/dependent/host_with_tls.yaml
@@ -0,0 +1,13 @@
+apiVersion: getambassador.io/v2
+kind: Host
+metadata:
+  name: gerrit-ambassador-host
+spec:
+  ambassador_id:
+    - my-id-1
+    - my-id-2
+  hostname: example.com
+  tlsContext:
+    name: gerrit-tls-context
+  tlsSecret:
+    name: tls-secret
\ No newline at end of file
diff --git a/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/ambassador/dependent/mappingGETReplica_primary_replica.yaml b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/ambassador/dependent/mappingGETReplica_primary_replica.yaml
new file mode 100644
index 0000000..841d1fb
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/ambassador/dependent/mappingGETReplica_primary_replica.yaml
@@ -0,0 +1,18 @@
+apiVersion: getambassador.io/v2
+kind: Mapping
+metadata:
+  name: gerrit-mapping-get-replica
+  namespace: gerrit
+spec:
+  ambassador_id:
+    - my-id-1
+    - my-id-2
+  bypass_auth: true
+  rewrite: ""
+  host: example.com
+  method: GET
+  prefix: /.*/info/refs
+  prefix_regex: true
+  query_parameters:
+    service: git-upload-pack
+  service: replica:48080
\ No newline at end of file
diff --git a/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/ambassador/dependent/mappingGET_receiver.yaml b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/ambassador/dependent/mappingGET_receiver.yaml
new file mode 100644
index 0000000..b179763
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/ambassador/dependent/mappingGET_receiver.yaml
@@ -0,0 +1,18 @@
+apiVersion: getambassador.io/v2
+kind: Mapping
+metadata:
+  name: gerrit-mapping-receiver-get
+  namespace: gerrit
+spec:
+  ambassador_id:
+    - my-id-1
+    - my-id-2
+  bypass_auth: true
+  rewrite: ""
+  host: example.com
+  method: GET
+  prefix: /.*/info/refs
+  prefix_regex: true
+  query_parameters:
+    service: git-receive-pack
+  service: receiver:48081
\ No newline at end of file
diff --git a/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/ambassador/dependent/mappingPOSTReplica_primary_replica.yaml b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/ambassador/dependent/mappingPOSTReplica_primary_replica.yaml
new file mode 100644
index 0000000..02f717a
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/ambassador/dependent/mappingPOSTReplica_primary_replica.yaml
@@ -0,0 +1,16 @@
+apiVersion: getambassador.io/v2
+kind: Mapping
+metadata:
+  name: gerrit-mapping-post-replica
+  namespace: gerrit
+spec:
+  ambassador_id:
+    - my-id-1
+    - my-id-2
+  bypass_auth: true
+  rewrite: ""
+  host: example.com
+  method: POST
+  prefix: /.*/git-upload-pack
+  prefix_regex: true
+  service: replica:48080
\ No newline at end of file
diff --git a/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/ambassador/dependent/mappingPrimary_primary_replica.yaml b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/ambassador/dependent/mappingPrimary_primary_replica.yaml
new file mode 100644
index 0000000..e6f52a5
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/ambassador/dependent/mappingPrimary_primary_replica.yaml
@@ -0,0 +1,14 @@
+apiVersion: getambassador.io/v2
+kind: Mapping
+metadata:
+  name: gerrit-mapping-primary
+  namespace: gerrit
+spec:
+  ambassador_id:
+    - my-id-1
+    - my-id-2
+  bypass_auth: true
+  rewrite: ""
+  host: example.com
+  prefix: /
+  service: primary:48080
\ No newline at end of file
diff --git a/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/ambassador/dependent/mapping_primary.yaml b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/ambassador/dependent/mapping_primary.yaml
new file mode 100644
index 0000000..4ea6c95
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/ambassador/dependent/mapping_primary.yaml
@@ -0,0 +1,14 @@
+apiVersion: getambassador.io/v2
+kind: Mapping
+metadata:
+  name: gerrit-mapping
+  namespace: gerrit
+spec:
+  ambassador_id:
+    - my-id-1
+    - my-id-2
+  bypass_auth: true
+  rewrite: ""
+  host: example.com
+  prefix: /
+  service: primary:48080
\ No newline at end of file
diff --git a/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/ambassador/dependent/mapping_receiver.yaml b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/ambassador/dependent/mapping_receiver.yaml
new file mode 100644
index 0000000..93b9eed
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/ambassador/dependent/mapping_receiver.yaml
@@ -0,0 +1,15 @@
+apiVersion: getambassador.io/v2
+kind: Mapping
+metadata:
+  name: gerrit-mapping-receiver
+  namespace: gerrit
+spec:
+  ambassador_id:
+    - my-id-1
+    - my-id-2
+  bypass_auth: true
+  rewrite: ""
+  host: example.com
+  prefix: /a/projects/.*|/.*/git-receive-pack
+  prefix_regex: true
+  service: receiver:48081
\ No newline at end of file
diff --git a/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/ambassador/dependent/mapping_replica.yaml b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/ambassador/dependent/mapping_replica.yaml
new file mode 100644
index 0000000..7fb2b7b
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/ambassador/dependent/mapping_replica.yaml
@@ -0,0 +1,14 @@
+apiVersion: getambassador.io/v2
+kind: Mapping
+metadata:
+  name: gerrit-mapping
+  namespace: gerrit
+spec:
+  ambassador_id:
+    - my-id-1
+    - my-id-2
+  bypass_auth: true
+  rewrite: ""
+  host: example.com
+  prefix: /
+  service: replica:48080
\ No newline at end of file
diff --git a/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/ambassador/dependent/tlscontext.yaml b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/ambassador/dependent/tlscontext.yaml
new file mode 100644
index 0000000..aee8ddb
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/ambassador/dependent/tlscontext.yaml
@@ -0,0 +1,13 @@
+apiVersion: getambassador.io/v2
+kind: TLSContext
+metadata:
+  name: gerrit-tls-context
+  namespace: gerrit
+spec:
+  ambassador_id:
+    - my-id-1
+    - my-id-2
+  secret: tls-secret
+  secret_namespacing: true
+  hosts:
+    - example.com
\ No newline at end of file
diff --git a/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/gerritnetwork_primary.yaml b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/gerritnetwork_primary.yaml
new file mode 100644
index 0000000..68b81f5
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/gerritnetwork_primary.yaml
@@ -0,0 +1,17 @@
+apiVersion: "gerritoperator.google.com/v1alpha1"
+kind: GerritNetwork
+metadata:
+  name: gerrit
+  namespace: gerrit
+spec:
+  ingress:
+    enabled: true
+    host: example.com
+    tls:
+      enabled: false
+    ambassador:
+      id: ["my-id-1", "my-id-2"]
+  primaryGerrit:
+    name: primary
+    httpPort: 48080
+    sshPort: 49418
diff --git a/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/gerritnetwork_primary_replica.yaml b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/gerritnetwork_primary_replica.yaml
new file mode 100644
index 0000000..443e465
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/gerritnetwork_primary_replica.yaml
@@ -0,0 +1,21 @@
+apiVersion: "gerritoperator.google.com/v1alpha1"
+kind: GerritNetwork
+metadata:
+  name: gerrit
+  namespace: gerrit
+spec:
+  ingress:
+    enabled: true
+    host: example.com
+    tls:
+      enabled: false
+    ambassador:
+      id: ["my-id-1", "my-id-2"]
+  primaryGerrit:
+    name: primary
+    httpPort: 48080
+    sshPort: 49418
+  gerritReplica:
+    name: replica
+    httpPort: 48080
+    sshPort: 49418
diff --git a/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/gerritnetwork_primary_replica_create_host.yaml b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/gerritnetwork_primary_replica_create_host.yaml
new file mode 100644
index 0000000..ef4f76c
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/gerritnetwork_primary_replica_create_host.yaml
@@ -0,0 +1,22 @@
+apiVersion: "gerritoperator.google.com/v1alpha1"
+kind: GerritNetwork
+metadata:
+  name: gerrit
+  namespace: gerrit
+spec:
+  ingress:
+    enabled: true
+    host: example.com
+    tls:
+      enabled: false
+    ambassador:
+      id: ["my-id-1", "my-id-2"]
+      createHost: true
+  primaryGerrit:
+    name: primary
+    httpPort: 48080
+    sshPort: 49418
+  gerritReplica:
+    name: replica
+    httpPort: 48080
+    sshPort: 49418
diff --git a/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/gerritnetwork_primary_replica_ssh.yaml b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/gerritnetwork_primary_replica_ssh.yaml
new file mode 100644
index 0000000..623890f
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/gerritnetwork_primary_replica_ssh.yaml
@@ -0,0 +1,21 @@
+apiVersion: "gerritoperator.google.com/v1alpha1"
+kind: GerritNetwork
+metadata:
+  name: gerrit
+  namespace: gerrit
+spec:
+  ingress:
+    enabled: true
+    host: example.com
+    tls:
+      enabled: false
+    ssh:
+      enabled: true
+  primaryGerrit:
+    name: primary
+    httpPort: 48080
+    sshPort: 49418
+  gerritReplica:
+    name: replica
+    httpPort: 48080
+    sshPort: 49419
diff --git a/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/gerritnetwork_primary_replica_tls.yaml b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/gerritnetwork_primary_replica_tls.yaml
new file mode 100644
index 0000000..21b9e59
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/gerritnetwork_primary_replica_tls.yaml
@@ -0,0 +1,22 @@
+apiVersion: "gerritoperator.google.com/v1alpha1"
+kind: GerritNetwork
+metadata:
+  name: gerrit
+  namespace: gerrit
+spec:
+  ingress:
+    enabled: true
+    host: example.com
+    tls:
+      enabled: true
+      secret: tls-secret
+    ambassador:
+      id: ["my-id-1", "my-id-2"]
+  primaryGerrit:
+    name: primary
+    httpPort: 48080
+    sshPort: 49418
+  gerritReplica:
+    name: replica
+    httpPort: 48080
+    sshPort: 49418
diff --git a/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/gerritnetwork_primary_replica_tls_create_host.yaml b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/gerritnetwork_primary_replica_tls_create_host.yaml
new file mode 100644
index 0000000..c84e3cc
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/gerritnetwork_primary_replica_tls_create_host.yaml
@@ -0,0 +1,23 @@
+apiVersion: "gerritoperator.google.com/v1alpha1"
+kind: GerritNetwork
+metadata:
+  name: gerrit
+  namespace: gerrit
+spec:
+  ingress:
+    enabled: true
+    host: example.com
+    tls:
+      enabled: true
+      secret: tls-secret
+    ambassador:
+      id: ["my-id-1", "my-id-2"]
+      createHost: true
+  primaryGerrit:
+    name: primary
+    httpPort: 48080
+    sshPort: 49418
+  gerritReplica:
+    name: replica
+    httpPort: 48080
+    sshPort: 49418
diff --git a/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/gerritnetwork_primary_ssh.yaml b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/gerritnetwork_primary_ssh.yaml
new file mode 100644
index 0000000..a6b3271
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/gerritnetwork_primary_ssh.yaml
@@ -0,0 +1,17 @@
+apiVersion: "gerritoperator.google.com/v1alpha1"
+kind: GerritNetwork
+metadata:
+  name: gerrit
+  namespace: gerrit
+spec:
+  ingress:
+    enabled: true
+    host: example.com
+    tls:
+      enabled: false
+    ssh:
+      enabled: true
+  primaryGerrit:
+    name: primary
+    httpPort: 48080
+    sshPort: 49418
diff --git a/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/gerritnetwork_receiver_replica.yaml b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/gerritnetwork_receiver_replica.yaml
new file mode 100644
index 0000000..e6da865
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/gerritnetwork_receiver_replica.yaml
@@ -0,0 +1,20 @@
+apiVersion: "gerritoperator.google.com/v1alpha1"
+kind: GerritNetwork
+metadata:
+  name: gerrit
+  namespace: gerrit
+spec:
+  ingress:
+    enabled: true
+    host: example.com
+    tls:
+      enabled: false
+    ambassador:
+      id: ["my-id-1", "my-id-2"]
+  gerritReplica:
+    name: replica
+    httpPort: 48080
+    sshPort: 49418
+  receiver:
+    name: receiver
+    httpPort: 48081
diff --git a/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/gerritnetwork_receiver_replica_ssh.yaml b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/gerritnetwork_receiver_replica_ssh.yaml
new file mode 100644
index 0000000..7efc649
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/gerritnetwork_receiver_replica_ssh.yaml
@@ -0,0 +1,20 @@
+apiVersion: "gerritoperator.google.com/v1alpha1"
+kind: GerritNetwork
+metadata:
+  name: gerrit
+  namespace: gerrit
+spec:
+  ingress:
+    enabled: true
+    host: example.com
+    tls:
+      enabled: false
+    ssh:
+      enabled: true
+  gerritReplica:
+    name: replica
+    httpPort: 48080
+    sshPort: 49419
+  receiver:
+    name: receiver
+    httpPort: 48081
diff --git a/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/gerritnetwork_receiver_replica_tls.yaml b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/gerritnetwork_receiver_replica_tls.yaml
new file mode 100644
index 0000000..d5bd3c0
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/gerritnetwork_receiver_replica_tls.yaml
@@ -0,0 +1,21 @@
+apiVersion: "gerritoperator.google.com/v1alpha1"
+kind: GerritNetwork
+metadata:
+  name: gerrit
+  namespace: gerrit
+spec:
+  ingress:
+    enabled: true
+    host: example.com
+    tls:
+      enabled: true
+      secret: tls-secret
+    ambassador:
+      id: ["my-id-1", "my-id-2"]
+  gerritReplica:
+    name: replica
+    httpPort: 48080
+    sshPort: 49418
+  receiver:
+    name: receiver
+    httpPort: 48081
diff --git a/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/gerritnetwork_replica.yaml b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/gerritnetwork_replica.yaml
new file mode 100644
index 0000000..915da97
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/gerritnetwork_replica.yaml
@@ -0,0 +1,17 @@
+apiVersion: "gerritoperator.google.com/v1alpha1"
+kind: GerritNetwork
+metadata:
+  name: gerrit
+  namespace: gerrit
+spec:
+  ingress:
+    enabled: true
+    host: example.com
+    tls:
+      enabled: false
+    ambassador:
+      id: ["my-id-1", "my-id-2"]
+  gerritReplica:
+    name: replica
+    httpPort: 48080
+    sshPort: 49418
diff --git a/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/gerritnetwork_replica_ssh.yaml b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/gerritnetwork_replica_ssh.yaml
new file mode 100644
index 0000000..efa54a7
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/gerritnetwork_replica_ssh.yaml
@@ -0,0 +1,17 @@
+apiVersion: "gerritoperator.google.com/v1alpha1"
+kind: GerritNetwork
+metadata:
+  name: gerrit
+  namespace: gerrit
+spec:
+  ingress:
+    enabled: true
+    host: example.com
+    tls:
+      enabled: false
+    ssh:
+      enabled: true
+  gerritReplica:
+    name: replica
+    httpPort: 48080
+    sshPort: 49419
diff --git a/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/ingress/dependent/ingress_primary.yaml b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/ingress/dependent/ingress_primary.yaml
new file mode 100644
index 0000000..9b18d5c
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/ingress/dependent/ingress_primary.yaml
@@ -0,0 +1,25 @@
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+  name: gerrit-ingress
+  namespace: gerrit
+  annotations:
+    nginx.ingress.kubernetes.io/use-regex: true
+    kubernetes.io/ingress.class: nginx
+    nginx.ingress.kubernetes.io/affinity: cookie
+    nginx.ingress.kubernetes.io/session-cookie-name: Gerrit_Session
+    nginx.ingress.kubernetes.io/session-cookie-path: /
+    nginx.ingress.kubernetes.io/session-cookie-max-age: 60
+    nginx.ingress.kubernetes.io/session-cookie-expires: 60
+spec:
+  rules:
+  - host: example.com
+    http:
+      paths:
+      - pathType: Prefix
+        path: "/"
+        backend:
+          service:
+            name: primary
+            port:
+              name: http
diff --git a/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/ingress/dependent/ingress_primary_replica.yaml b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/ingress/dependent/ingress_primary_replica.yaml
new file mode 100644
index 0000000..d6aae78
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/ingress/dependent/ingress_primary_replica.yaml
@@ -0,0 +1,38 @@
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+  name: gerrit-ingress
+  namespace: gerrit
+  annotations:
+    nginx.ingress.kubernetes.io/use-regex: true
+    kubernetes.io/ingress.class: nginx
+    nginx.ingress.kubernetes.io/configuration-snippet: |-
+      if ($args ~ service=git-upload-pack){
+        set $proxy_upstream_name "gerrit-replica-http";
+        set $proxy_host $proxy_upstream_name;
+        set $service_name "replica";
+      }
+    nginx.ingress.kubernetes.io/affinity: cookie
+    nginx.ingress.kubernetes.io/session-cookie-name: Gerrit_Session
+    nginx.ingress.kubernetes.io/session-cookie-path: /
+    nginx.ingress.kubernetes.io/session-cookie-max-age: 60
+    nginx.ingress.kubernetes.io/session-cookie-expires: 60
+spec:
+  rules:
+  - host: example.com
+    http:
+      paths:
+      - pathType: Prefix
+        path: "/.*/git-upload-pack"
+        backend:
+          service:
+            name: replica
+            port:
+              name: http
+      - pathType: Prefix
+        path: "/"
+        backend:
+          service:
+            name: primary
+            port:
+              name: http
diff --git a/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/ingress/dependent/ingress_primary_replica_tls.yaml b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/ingress/dependent/ingress_primary_replica_tls.yaml
new file mode 100644
index 0000000..adbe808
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/ingress/dependent/ingress_primary_replica_tls.yaml
@@ -0,0 +1,42 @@
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+  name: gerrit-ingress
+  namespace: gerrit
+  annotations:
+    nginx.ingress.kubernetes.io/use-regex: true
+    kubernetes.io/ingress.class: nginx
+    nginx.ingress.kubernetes.io/configuration-snippet: |-
+      if ($args ~ service=git-upload-pack){
+        set $proxy_upstream_name "gerrit-replica-http";
+        set $proxy_host $proxy_upstream_name;
+        set $service_name "replica";
+      }
+    nginx.ingress.kubernetes.io/affinity: cookie
+    nginx.ingress.kubernetes.io/session-cookie-name: Gerrit_Session
+    nginx.ingress.kubernetes.io/session-cookie-path: /
+    nginx.ingress.kubernetes.io/session-cookie-max-age: 60
+    nginx.ingress.kubernetes.io/session-cookie-expires: 60
+spec:
+  tls:
+  - hosts:
+    - example.com
+    secretName: tls-secret
+  rules:
+  - host: example.com
+    http:
+      paths:
+      - pathType: Prefix
+        path: "/.*/git-upload-pack"
+        backend:
+          service:
+            name: replica
+            port:
+              name: http
+      - pathType: Prefix
+        path: "/"
+        backend:
+          service:
+            name: primary
+            port:
+              name: http
diff --git a/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/ingress/dependent/ingress_receiver_replica.yaml b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/ingress/dependent/ingress_receiver_replica.yaml
new file mode 100644
index 0000000..75a37c7
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/ingress/dependent/ingress_receiver_replica.yaml
@@ -0,0 +1,45 @@
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+  name: gerrit-ingress
+  namespace: gerrit
+  annotations:
+    nginx.ingress.kubernetes.io/use-regex: true
+    kubernetes.io/ingress.class: nginx
+    nginx.ingress.kubernetes.io/affinity: cookie
+    nginx.ingress.kubernetes.io/session-cookie-name: Gerrit_Session
+    nginx.ingress.kubernetes.io/session-cookie-path: /
+    nginx.ingress.kubernetes.io/session-cookie-max-age: 60
+    nginx.ingress.kubernetes.io/session-cookie-expires: 60
+    nginx.ingress.kubernetes.io/configuration-snippet: |-
+      if ($args ~ service=git-receive-pack){
+        set $proxy_upstream_name "gerrit-receiver-http";
+        set $proxy_host $proxy_upstream_name;
+        set $service_name "receiver";
+      }
+spec:
+  rules:
+  - host: example.com
+    http:
+      paths:
+      - pathType: Prefix
+        path: "/a/projects"
+        backend:
+          service:
+            name: receiver
+            port:
+              name: http
+      - pathType: Prefix
+        path: "/.*/git-receive-pack"
+        backend:
+          service:
+            name: receiver
+            port:
+              name: http
+      - pathType: Prefix
+        path: "/"
+        backend:
+          service:
+            name: replica
+            port:
+              name: http
diff --git a/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/ingress/dependent/ingress_receiver_replica_tls.yaml b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/ingress/dependent/ingress_receiver_replica_tls.yaml
new file mode 100644
index 0000000..ad4cfbf
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/ingress/dependent/ingress_receiver_replica_tls.yaml
@@ -0,0 +1,49 @@
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+  name: gerrit-ingress
+  namespace: gerrit
+  annotations:
+    nginx.ingress.kubernetes.io/use-regex: true
+    kubernetes.io/ingress.class: nginx
+    nginx.ingress.kubernetes.io/affinity: cookie
+    nginx.ingress.kubernetes.io/session-cookie-name: Gerrit_Session
+    nginx.ingress.kubernetes.io/session-cookie-path: /
+    nginx.ingress.kubernetes.io/session-cookie-max-age: 60
+    nginx.ingress.kubernetes.io/session-cookie-expires: 60
+    nginx.ingress.kubernetes.io/configuration-snippet: |-
+      if ($args ~ service=git-receive-pack){
+        set $proxy_upstream_name "gerrit-receiver-http";
+        set $proxy_host $proxy_upstream_name;
+        set $service_name "receiver";
+      }
+spec:
+  tls:
+  - hosts:
+    - example.com
+    secretName: tls-secret
+  rules:
+  - host: example.com
+    http:
+      paths:
+      - pathType: Prefix
+        path: "/a/projects"
+        backend:
+          service:
+            name: receiver
+            port:
+              name: http
+      - pathType: Prefix
+        path: "/.*/git-receive-pack"
+        backend:
+          service:
+            name: receiver
+            port:
+              name: http
+      - pathType: Prefix
+        path: "/"
+        backend:
+          service:
+            name: replica
+            port:
+              name: http
diff --git a/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/ingress/dependent/ingress_replica.yaml b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/ingress/dependent/ingress_replica.yaml
new file mode 100644
index 0000000..07dfe7d
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/ingress/dependent/ingress_replica.yaml
@@ -0,0 +1,25 @@
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+  name: gerrit-ingress
+  namespace: gerrit
+  annotations:
+    nginx.ingress.kubernetes.io/use-regex: true
+    kubernetes.io/ingress.class: nginx
+    nginx.ingress.kubernetes.io/affinity: cookie
+    nginx.ingress.kubernetes.io/session-cookie-name: Gerrit_Session
+    nginx.ingress.kubernetes.io/session-cookie-path: /
+    nginx.ingress.kubernetes.io/session-cookie-max-age: 60
+    nginx.ingress.kubernetes.io/session-cookie-expires: 60
+spec:
+  rules:
+  - host: example.com
+    http:
+      paths:
+      - pathType: Prefix
+        path: "/"
+        backend:
+          service:
+            name: replica
+            port:
+              name: http
diff --git a/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/istio/dependent/gateway.yaml b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/istio/dependent/gateway.yaml
new file mode 100644
index 0000000..ccbab63
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/istio/dependent/gateway.yaml
@@ -0,0 +1,17 @@
+apiVersion: networking.istio.io/v1beta1
+kind: Gateway
+metadata:
+  name: gerrit-istio-gateway
+  namespace: gerrit
+spec:
+  selector:
+    istio: ingressgateway
+  servers:
+  - port:
+      number: 80
+      name: http
+      protocol: HTTP
+    hosts:
+    - example.com
+    tls:
+      httpsRedirect: false
diff --git a/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/istio/dependent/gateway_primary_replica_ssh.yaml b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/istio/dependent/gateway_primary_replica_ssh.yaml
new file mode 100644
index 0000000..70a38f2
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/istio/dependent/gateway_primary_replica_ssh.yaml
@@ -0,0 +1,29 @@
+apiVersion: networking.istio.io/v1beta1
+kind: Gateway
+metadata:
+  name: gerrit-istio-gateway
+  namespace: gerrit
+spec:
+  selector:
+    istio: ingressgateway
+  servers:
+  - port:
+      number: 80
+      name: http
+      protocol: HTTP
+    hosts:
+    - example.com
+    tls:
+      httpsRedirect: false
+  - port:
+      number: 49418
+      name: ssh-primary
+      protocol: TCP
+    hosts:
+    - example.com
+  - port:
+      number: 49419
+      name: ssh-replica
+      protocol: TCP
+    hosts:
+    - example.com
diff --git a/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/istio/dependent/gateway_primary_ssh.yaml b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/istio/dependent/gateway_primary_ssh.yaml
new file mode 100644
index 0000000..7815aba
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/istio/dependent/gateway_primary_ssh.yaml
@@ -0,0 +1,23 @@
+apiVersion: networking.istio.io/v1beta1
+kind: Gateway
+metadata:
+  name: gerrit-istio-gateway
+  namespace: gerrit
+spec:
+  selector:
+    istio: ingressgateway
+  servers:
+  - port:
+      number: 80
+      name: http
+      protocol: HTTP
+    hosts:
+    - example.com
+    tls:
+      httpsRedirect: false
+  - port:
+      number: 49418
+      name: ssh-primary
+      protocol: TCP
+    hosts:
+    - example.com
diff --git a/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/istio/dependent/gateway_receiver_replica_ssh.yaml b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/istio/dependent/gateway_receiver_replica_ssh.yaml
new file mode 100644
index 0000000..04212e3
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/istio/dependent/gateway_receiver_replica_ssh.yaml
@@ -0,0 +1,23 @@
+apiVersion: networking.istio.io/v1beta1
+kind: Gateway
+metadata:
+  name: gerrit-istio-gateway
+  namespace: gerrit
+spec:
+  selector:
+    istio: ingressgateway
+  servers:
+  - port:
+      number: 80
+      name: http
+      protocol: HTTP
+    hosts:
+    - example.com
+    tls:
+      httpsRedirect: false
+  - port:
+      number: 49419
+      name: ssh-replica
+      protocol: TCP
+    hosts:
+    - example.com
diff --git a/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/istio/dependent/gateway_replica_ssh.yaml b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/istio/dependent/gateway_replica_ssh.yaml
new file mode 100644
index 0000000..04212e3
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/istio/dependent/gateway_replica_ssh.yaml
@@ -0,0 +1,23 @@
+apiVersion: networking.istio.io/v1beta1
+kind: Gateway
+metadata:
+  name: gerrit-istio-gateway
+  namespace: gerrit
+spec:
+  selector:
+    istio: ingressgateway
+  servers:
+  - port:
+      number: 80
+      name: http
+      protocol: HTTP
+    hosts:
+    - example.com
+    tls:
+      httpsRedirect: false
+  - port:
+      number: 49419
+      name: ssh-replica
+      protocol: TCP
+    hosts:
+    - example.com
diff --git a/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/istio/dependent/gateway_tls.yaml b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/istio/dependent/gateway_tls.yaml
new file mode 100644
index 0000000..9f64ad5
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/istio/dependent/gateway_tls.yaml
@@ -0,0 +1,26 @@
+apiVersion: networking.istio.io/v1beta1
+kind: Gateway
+metadata:
+  name: gerrit-istio-gateway
+  namespace: gerrit
+spec:
+  selector:
+    istio: ingressgateway
+  servers:
+  - port:
+      number: 80
+      name: http
+      protocol: HTTP
+    hosts:
+    - example.com
+    tls:
+      httpsRedirect: true
+  - port:
+      number: 443
+      name: https
+      protocol: HTTPS
+    hosts:
+    - example.com
+    tls:
+      mode: SIMPLE
+      credentialName: tls-secret
diff --git a/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/istio/dependent/virtualservice_primary.yaml b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/istio/dependent/virtualservice_primary.yaml
new file mode 100644
index 0000000..ab1bf7a
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/istio/dependent/virtualservice_primary.yaml
@@ -0,0 +1,17 @@
+apiVersion: networking.istio.io/v1beta1
+kind: VirtualService
+metadata:
+  name: gerrit-gerrit-http-virtual-service
+  namespace: gerrit
+spec:
+  hosts:
+  - example.com
+  gateways:
+  - gerrit/gerrit-istio-gateway
+  http:
+  - name: gerrit-primary-primary
+    route:
+    - destination:
+        port:
+          number: 48080
+        host: primary.gerrit.svc.cluster.local
diff --git a/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/istio/dependent/virtualservice_primary_replica.yaml b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/istio/dependent/virtualservice_primary_replica.yaml
new file mode 100644
index 0000000..f97524d
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/istio/dependent/virtualservice_primary_replica.yaml
@@ -0,0 +1,37 @@
+apiVersion: networking.istio.io/v1beta1
+kind: VirtualService
+metadata:
+  name: gerrit-gerrit-http-virtual-service
+  namespace: gerrit
+spec:
+  hosts:
+  - example.com
+  gateways:
+  - gerrit/gerrit-istio-gateway
+  http:
+  - name: gerrit-replica-replica
+    match:
+    - uri:
+        regex: "^/(.*)/info/refs$"
+      queryParams:
+        service:
+          exact: git-upload-pack
+      ignoreUriCase: true
+      method:
+        exact: GET
+    - uri:
+        regex: "^/(.*)/git-upload-pack$"
+      ignoreUriCase: true
+      method:
+        exact: POST
+    route:
+    - destination:
+        port:
+          number: 48080
+        host: replica.gerrit.svc.cluster.local
+  - name: gerrit-primary-primary
+    route:
+    - destination:
+        port:
+          number: 48080
+        host: primary.gerrit.svc.cluster.local
diff --git a/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/istio/dependent/virtualservice_primary_replica_ssh.yaml b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/istio/dependent/virtualservice_primary_replica_ssh.yaml
new file mode 100644
index 0000000..30ffb44
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/istio/dependent/virtualservice_primary_replica_ssh.yaml
@@ -0,0 +1,52 @@
+apiVersion: networking.istio.io/v1beta1
+kind: VirtualService
+metadata:
+  name: gerrit-gerrit-http-virtual-service
+  namespace: gerrit
+spec:
+  hosts:
+  - example.com
+  gateways:
+  - gerrit/gerrit-istio-gateway
+  http:
+  - name: gerrit-replica-replica
+    match:
+    - uri:
+        regex: "^/(.*)/info/refs$"
+      queryParams:
+        service:
+          exact: git-upload-pack
+      ignoreUriCase: true
+      method:
+        exact: GET
+    - uri:
+        regex: "^/(.*)/git-upload-pack$"
+      ignoreUriCase: true
+      method:
+        exact: POST
+    route:
+    - destination:
+        port:
+          number: 48080
+        host: replica.gerrit.svc.cluster.local
+  - name: gerrit-primary-primary
+    route:
+    - destination:
+        port:
+          number: 48080
+        host: primary.gerrit.svc.cluster.local
+  tcp:
+  - match:
+    - port: 49418
+    route:
+    - destination:
+        port:
+          number: 49418
+        host: primary.gerrit.svc.cluster.local
+  - match:
+    - port: 49419
+    route:
+    - destination:
+        port:
+          number: 49419
+        host: replica.gerrit.svc.cluster.local
diff --git a/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/istio/dependent/virtualservice_primary_ssh.yaml b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/istio/dependent/virtualservice_primary_ssh.yaml
new file mode 100644
index 0000000..73f5bd7
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/istio/dependent/virtualservice_primary_ssh.yaml
@@ -0,0 +1,25 @@
+apiVersion: networking.istio.io/v1beta1
+kind: VirtualService
+metadata:
+  name: gerrit-gerrit-http-virtual-service
+  namespace: gerrit
+spec:
+  hosts:
+  - example.com
+  gateways:
+  - gerrit/gerrit-istio-gateway
+  http:
+  - name: gerrit-primary-primary
+    route:
+    - destination:
+        port:
+          number: 48080
+        host: primary.gerrit.svc.cluster.local
+  tcp:
+  - match:
+    - port: 49418
+    route:
+    - destination:
+        port:
+          number: 49418
+        host: primary.gerrit.svc.cluster.local
diff --git a/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/istio/dependent/virtualservice_receiver_replica.yaml b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/istio/dependent/virtualservice_receiver_replica.yaml
new file mode 100644
index 0000000..3a224d6
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/istio/dependent/virtualservice_receiver_replica.yaml
@@ -0,0 +1,33 @@
+apiVersion: networking.istio.io/v1beta1
+kind: VirtualService
+metadata:
+  name: gerrit-gerrit-http-virtual-service
+  namespace: gerrit
+spec:
+  hosts:
+  - example.com
+  gateways:
+  - gerrit/gerrit-istio-gateway
+  http:
+  - name: receiver-receiver
+    match:
+    - uri:
+        prefix: "/a/projects/"
+    - uri:
+        regex: "^/(.*)/git-receive-pack$"
+    - uri:
+        regex: "^/(.*)/info/refs$"
+      queryParams:
+        service:
+          exact: git-receive-pack
+    route:
+    - destination:
+        port:
+          number: 48081
+        host: receiver.gerrit.svc.cluster.local
+  - name: gerrit-replica-replica
+    route:
+    - destination:
+        port:
+          number: 48080
+        host: replica.gerrit.svc.cluster.local
diff --git a/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/istio/dependent/virtualservice_receiver_replica_ssh.yaml b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/istio/dependent/virtualservice_receiver_replica_ssh.yaml
new file mode 100644
index 0000000..422e2e9
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/istio/dependent/virtualservice_receiver_replica_ssh.yaml
@@ -0,0 +1,41 @@
+apiVersion: networking.istio.io/v1beta1
+kind: VirtualService
+metadata:
+  name: gerrit-gerrit-http-virtual-service
+  namespace: gerrit
+spec:
+  hosts:
+  - example.com
+  gateways:
+  - gerrit/gerrit-istio-gateway
+  http:
+  - name: receiver-receiver
+    match:
+    - uri:
+        prefix: "/a/projects/"
+    - uri:
+        regex: "^/(.*)/git-receive-pack$"
+    - uri:
+        regex: "^/(.*)/info/refs$"
+      queryParams:
+        service:
+          exact: git-receive-pack
+    route:
+    - destination:
+        port:
+          number: 48081
+        host: receiver.gerrit.svc.cluster.local
+  - name: gerrit-replica-replica
+    route:
+    - destination:
+        port:
+          number: 48080
+        host: replica.gerrit.svc.cluster.local
+  tcp:
+  - match:
+    - port: 49419
+    route:
+    - destination:
+        port:
+          number: 49419
+        host: replica.gerrit.svc.cluster.local
diff --git a/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/istio/dependent/virtualservice_replica.yaml b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/istio/dependent/virtualservice_replica.yaml
new file mode 100644
index 0000000..d077def
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/istio/dependent/virtualservice_replica.yaml
@@ -0,0 +1,17 @@
+apiVersion: networking.istio.io/v1beta1
+kind: VirtualService
+metadata:
+  name: gerrit-gerrit-http-virtual-service
+  namespace: gerrit
+spec:
+  hosts:
+  - example.com
+  gateways:
+  - gerrit/gerrit-istio-gateway
+  http:
+  - name: gerrit-replica-replica
+    route:
+    - destination:
+        port:
+          number: 48080
+        host: replica.gerrit.svc.cluster.local
diff --git a/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/istio/dependent/virtualservice_replica_ssh.yaml b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/istio/dependent/virtualservice_replica_ssh.yaml
new file mode 100644
index 0000000..d9e7fd3
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/network/istio/dependent/virtualservice_replica_ssh.yaml
@@ -0,0 +1,25 @@
+apiVersion: networking.istio.io/v1beta1
+kind: VirtualService
+metadata:
+  name: gerrit-gerrit-http-virtual-service
+  namespace: gerrit
+spec:
+  hosts:
+  - example.com
+  gateways:
+  - gerrit/gerrit-istio-gateway
+  http:
+  - name: gerrit-replica-replica
+    route:
+    - destination:
+        port:
+          number: 48080
+        host: replica.gerrit.svc.cluster.local
+  tcp:
+  - match:
+    - port: 49419
+    route:
+    - destination:
+        port:
+          number: 49419
+        host: replica.gerrit.svc.cluster.local
diff --git a/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/receiver/dependent/deployment.yaml b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/receiver/dependent/deployment.yaml
new file mode 100644
index 0000000..4857152
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/receiver/dependent/deployment.yaml
@@ -0,0 +1,118 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: receiver
+  namespace: gerrit
+  labels:
+    app.kubernetes.io/managed-by: gerrit-operator
+    app.kubernetes.io/name: gerrit
+    app.kubernetes.io/part-of: receiver
+    app.kubernetes.io/created-by: ReceiverReconciler
+    app.kubernetes.io/instance: receiver
+    app.kubernetes.io/version: unknown
+    app.kubernetes.io/component: receiver-deployment-receiver
+spec:
+  replicas: 1
+  strategy:
+    rollingUpdate:
+      maxSurge: 1
+      maxUnavailable: 1
+  selector:
+    matchLabels:
+      app.kubernetes.io/managed-by: gerrit-operator
+      app.kubernetes.io/name: gerrit
+      app.kubernetes.io/part-of: receiver
+      app.kubernetes.io/instance: receiver
+      app.kubernetes.io/component: receiver-deployment-receiver
+  template:
+    metadata:
+      labels:
+        app.kubernetes.io/managed-by: gerrit-operator
+        app.kubernetes.io/name: gerrit
+        app.kubernetes.io/part-of: receiver
+        app.kubernetes.io/created-by: ReceiverReconciler
+        app.kubernetes.io/instance: receiver
+        app.kubernetes.io/version: unknown
+        app.kubernetes.io/component: receiver-deployment-receiver
+    spec:
+      tolerations:
+      - key: key1
+        operator: Equal
+        value: value1
+        effect: NoSchedule
+      topologySpreadConstraints:
+      - maxSkew: 1
+        topologyKey: zone
+        whenUnsatisfiable: DoNotSchedule
+        labelSelector:
+          matchLabels:
+            foo: bar
+      affinity:
+        nodeAffinity:
+          requiredDuringSchedulingIgnoredDuringExecution:
+            nodeSelectorTerms:
+            - matchExpressions:
+              - key: disktype
+                operator: In
+                values:
+                - ssd
+      priorityClassName: prio
+      securityContext:
+        fsGroup: 100
+      imagePullSecrets: []
+      initContainers: []
+      containers:
+      - name: apache-git-http-backend
+        imagePullPolicy: Always
+        image: docker.io/k8sgerrit/apache-git-http-backend:latest
+        env:
+        - name: POD_NAME
+          valueFrom:
+            fieldRef:
+              fieldPath: metadata.name
+        ports:
+        - name: http
+          containerPort: 80
+        resources:
+          requests:
+            cpu: 1
+            memory: 5Gi
+          limits:
+            cpu: 1
+            memory: 6Gi
+
+        readinessProbe:
+          tcpSocket:
+            port: 80
+          initialDelaySeconds: 0
+          periodSeconds: 10
+          timeoutSeconds: 1
+          successThreshold: 1
+          failureThreshold: 3
+
+        livenessProbe:
+          tcpSocket:
+            port: 80
+          initialDelaySeconds: 0
+          periodSeconds: 10
+          timeoutSeconds: 1
+          successThreshold: 1
+          failureThreshold: 3
+
+        volumeMounts:
+        - name: shared
+          subPathExpr: "logs/$(POD_NAME)"
+          mountPath: /var/log/apache2
+        - name: apache-credentials
+          mountPath: /var/apache/credentials/.htpasswd
+          subPath: .htpasswd
+        - name: shared
+          subPath: git
+          mountPath: /var/gerrit/git
+      volumes:
+      - name: shared
+        persistentVolumeClaim:
+          claimName: shared-pvc
+      - name: apache-credentials
+        secret:
+          secretName: apache-credentials
diff --git a/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/receiver/dependent/deployment_minimal.yaml b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/receiver/dependent/deployment_minimal.yaml
new file mode 100644
index 0000000..92d0752
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/receiver/dependent/deployment_minimal.yaml
@@ -0,0 +1,79 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: receiver
+  namespace: gerrit
+  labels:
+    app.kubernetes.io/managed-by: gerrit-operator
+    app.kubernetes.io/name: gerrit
+    app.kubernetes.io/part-of: receiver
+    app.kubernetes.io/created-by: ReceiverReconciler
+    app.kubernetes.io/instance: receiver
+    app.kubernetes.io/version: unknown
+    app.kubernetes.io/component: receiver-deployment-receiver
+spec:
+  replicas: 1
+  strategy:
+    rollingUpdate:
+      maxSurge: 1
+      maxUnavailable: 1
+  selector:
+    matchLabels:
+      app.kubernetes.io/managed-by: gerrit-operator
+      app.kubernetes.io/name: gerrit
+      app.kubernetes.io/part-of: receiver
+      app.kubernetes.io/instance: receiver
+      app.kubernetes.io/component: receiver-deployment-receiver
+  template:
+    metadata:
+      labels:
+        app.kubernetes.io/managed-by: gerrit-operator
+        app.kubernetes.io/name: gerrit
+        app.kubernetes.io/part-of: receiver
+        app.kubernetes.io/created-by: ReceiverReconciler
+        app.kubernetes.io/instance: receiver
+        app.kubernetes.io/version: unknown
+        app.kubernetes.io/component: receiver-deployment-receiver
+    spec:
+      securityContext:
+        fsGroup: 100
+      imagePullSecrets: []
+      initContainers: []
+      containers:
+      - name: apache-git-http-backend
+        imagePullPolicy: Always
+        image: docker.io/k8sgerrit/apache-git-http-backend:latest
+        env:
+        - name: POD_NAME
+          valueFrom:
+            fieldRef:
+              fieldPath: metadata.name
+        ports:
+        - name: http
+          containerPort: 80
+
+        readinessProbe:
+          tcpSocket:
+            port: 80
+
+        livenessProbe:
+          tcpSocket:
+            port: 80
+
+        volumeMounts:
+        - name: shared
+          subPathExpr: "logs/$(POD_NAME)"
+          mountPath: /var/log/apache2
+        - name: apache-credentials
+          mountPath: /var/apache/credentials/.htpasswd
+          subPath: .htpasswd
+        - name: shared
+          subPath: git
+          mountPath: /var/gerrit/git
+      volumes:
+      - name: shared
+        persistentVolumeClaim:
+          claimName: shared-pvc
+      - name: apache-credentials
+        secret:
+          secretName: apache-credentials
diff --git a/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/receiver/dependent/service.yaml b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/receiver/dependent/service.yaml
new file mode 100644
index 0000000..907ee1e
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/receiver/dependent/service.yaml
@@ -0,0 +1,25 @@
+apiVersion: v1
+kind: Service
+metadata:
+  name: receiver
+  namespace: gerrit
+  labels:
+    app.kubernetes.io/managed-by: gerrit-operator
+    app.kubernetes.io/name: gerrit
+    app.kubernetes.io/part-of: receiver
+    app.kubernetes.io/created-by: ReceiverReconciler
+    app.kubernetes.io/instance: receiver
+    app.kubernetes.io/version: unknown
+    app.kubernetes.io/component: receiver-service
+spec:
+  type: NodePort
+  ports:
+  - name: http
+    port: 80
+    targetPort: 80
+  selector:
+    app.kubernetes.io/managed-by: gerrit-operator
+    app.kubernetes.io/name: gerrit
+    app.kubernetes.io/part-of: receiver
+    app.kubernetes.io/instance: receiver
+    app.kubernetes.io/component: receiver-deployment-receiver
diff --git a/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/receiver/receiver.yaml b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/receiver/receiver.yaml
new file mode 100644
index 0000000..3364ba0
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/receiver/receiver.yaml
@@ -0,0 +1,105 @@
+apiVersion: "gerritoperator.google.com/v1alpha6"
+kind: Receiver
+metadata:
+  name: receiver
+  namespace: gerrit
+spec:
+  tolerations:
+  - key: key1
+    operator: Equal
+    value: value1
+    effect: NoSchedule
+
+  affinity:
+    nodeAffinity:
+      requiredDuringSchedulingIgnoredDuringExecution:
+        nodeSelectorTerms:
+        - matchExpressions:
+          - key: disktype
+            operator: In
+            values:
+            - ssd
+
+  topologySpreadConstraints:
+  - maxSkew: 1
+    topologyKey: zone
+    whenUnsatisfiable: DoNotSchedule
+    labelSelector:
+      matchLabels:
+        foo: bar
+
+  priorityClassName: "prio"
+
+  replicas: 1
+  maxSurge: 1
+  maxUnavailable: 1
+
+  resources:
+    requests:
+      cpu: 1
+      memory: 5Gi
+    limits:
+      cpu: 1
+      memory: 6Gi
+
+  readinessProbe:
+    initialDelaySeconds: 0
+    periodSeconds: 10
+    timeoutSeconds: 1
+    successThreshold: 1
+    failureThreshold: 3
+
+  livenessProbe:
+    initialDelaySeconds: 0
+    periodSeconds: 10
+    timeoutSeconds: 1
+    successThreshold: 1
+    failureThreshold: 3
+
+  service:
+    type: NodePort
+    httpPort: 80
+
+  credentialSecretRef: apache-credentials
+
+  containerImages:
+    imagePullSecrets: []
+    imagePullPolicy: Always
+    gerritImages:
+      registry: docker.io
+      org: k8sgerrit
+      tag: latest
+    busyBox:
+      registry: docker.io
+      tag: latest
+
+  storage:
+    storageClasses:
+      readWriteOnce: default
+      readWriteMany: shared-storage
+      nfsWorkaround:
+        enabled: false
+        chownOnStartup: false
+        idmapdConfig: |-
+          [General]
+            Verbosity = 0
+            Domain = localdomain.com
+
+          [Mapping]
+            Nobody-User = nobody
+            Nobody-Group = nogroup
+
+    sharedStorage:
+      externalPVC:
+        enabled: false
+        claimName: ""
+      size: 1Gi
+      volumeName: ""
+      selector:
+        matchLabels:
+          volume-type: ssd
+          aws-availability-zone: us-east-1
+
+  ingress:
+    host: example.com
+    tlsEnabled: false
diff --git a/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/receiver/receiver_minimal.yaml b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/receiver/receiver_minimal.yaml
new file mode 100644
index 0000000..8fdf155
--- /dev/null
+++ b/charts/k8s-gerrit/operator/src/test/resources/com/google/gerrit/k8s/operator/receiver/receiver_minimal.yaml
@@ -0,0 +1,15 @@
+apiVersion: "gerritoperator.google.com/v1alpha6"
+kind: Receiver
+metadata:
+  name: receiver
+  namespace: gerrit
+spec:
+  credentialSecretRef: apache-credentials
+
+  storage:
+    storageClasses:
+      readWriteOnce: default
+      readWriteMany: shared-storage
+
+    sharedStorage:
+      size: 1Gi