[Operator] Add ValidationWebhook for GitGc

Currently, the operator validates whether two GitGc jobs conflict, i.e.
are working on the same project, during reconciliation. If a conflict is
detected, reconciliation was stopped and the GitGc resource marked in
the status. With this procedure the conflicting resource would however
remain in the cluster and on each restart of the operator, conflicts
would again have to be checked.

A better solution is to use validating AdmissionWebhooks. This change
adds such a webhook for GitGc resources.

Change-Id: I4329f5462757d02390157af3074625fa47b2b7ef
diff --git a/operator/k8s/operator.yaml b/operator/k8s/operator.yaml
deleted file mode 100644
index 0d43bdb..0000000
--- a/operator/k8s/operator.yaml
+++ /dev/null
@@ -1,149 +0,0 @@
-apiVersion: v1
-kind: Namespace
-metadata:
-  name: gerrit-operator
-
----
-apiVersion: v1
-kind: ServiceAccount
-metadata:
-  name: gerrit-operator
-  namespace: gerrit-operator
-
-## Required to use an external/persistent keystore, otherwise a keystore using
-## self-signed certificates will be generated
-# ---
-# apiVersion: v1
-# kind: Secret
-# metadata:
-#   name:  gerrit-operator-ssl
-#   namespace: gerrit-operator
-# data:
-#   keystore.jks: # base64-encoded Java keystore
-#   keystore.password: # base64-encoded Java keystore password
-# type: Opaque
-
----
-apiVersion: apps/v1
-kind: Deployment
-metadata:
-  name: gerrit-operator
-  namespace: gerrit-operator
-spec:
-  selector:
-    matchLabels:
-      app: gerrit-operator
-  template:
-    metadata:
-      labels:
-        app: gerrit-operator
-    spec:
-      serviceAccountName: gerrit-operator
-      containers:
-      - name: operator
-        image: k8sgerrit/gerrit-operator
-        imagePullPolicy: Always
-        env:
-        - name: NAMESPACE
-          valueFrom:
-            fieldRef:
-              fieldPath: metadata.namespace
-        ports:
-        - containerPort: 80
-        readinessProbe:
-          httpGet:
-            path: /health
-            port: 8080
-            scheme: HTTPS
-          initialDelaySeconds: 1
-        livenessProbe:
-          httpGet:
-            path: /health
-            port: 8080
-            scheme: HTTPS
-          initialDelaySeconds: 30
-      ## Only required, if an external/persistent keystore is being used.
-      #   volumeMounts:
-      #   - name: ssl
-      #     readOnly: true
-      #     mountPath: /operator
-      # volumes:
-      # - name: ssl
-      #   secret:
-      #     secretName: gerrit-operator-ssl
-
----
-apiVersion: rbac.authorization.k8s.io/v1
-kind: ClusterRoleBinding
-metadata:
-  name: gerrit-operator-admin
-subjects:
-- kind: ServiceAccount
-  name: gerrit-operator
-  namespace: gerrit-operator
-roleRef:
-  kind: ClusterRole
-  name: gerrit-operator
-  apiGroup: ""
-
----
-apiVersion: rbac.authorization.k8s.io/v1
-kind: ClusterRole
-metadata:
-  name: gerrit-operator
-rules:
-- apiGroups:
-  - "batch"
-  resources:
-  - cronjobs
-  verbs:
-  - '*'
-- apiGroups:
-  - "apps"
-  resources:
-  - statefulsets
-  - deployments
-  verbs:
-  - '*'
-- apiGroups:
-  - ""
-  resources:
-  - configmaps
-  - persistentvolumeclaims
-  - secrets
-  - services
-  verbs:
-  - '*'
-- apiGroups:
-  - "storage.k8s.io"
-  resources:
-  - storageclasses
-  verbs:
-  - 'get'
-  - 'list'
-- apiGroups:
-  - "apiextensions.k8s.io"
-  resources:
-  - customresourcedefinitions
-  verbs:
-  - '*'
-- apiGroups:
-  - "networking.k8s.io"
-  resources:
-  - ingresses
-  verbs:
-  - '*'
-- apiGroups:
-  - "gerritoperator.google.com"
-  resources:
-  - '*'
-  verbs:
-  - '*'
-- apiGroups:
-  - "networking.istio.io"
-  resources:
-  - "gateways"
-  - "virtualservices"
-  - "destinationrules"
-  verbs:
-  - '*'
diff --git a/operator/k8s/operator/namespace.yaml b/operator/k8s/operator/namespace.yaml
new file mode 100644
index 0000000..9ce8374
--- /dev/null
+++ b/operator/k8s/operator/namespace.yaml
@@ -0,0 +1,4 @@
+apiVersion: v1
+kind: Namespace
+metadata:
+  name: gerrit-operator
diff --git a/operator/k8s/operator/operator.yaml b/operator/k8s/operator/operator.yaml
new file mode 100644
index 0000000..09bbb60
--- /dev/null
+++ b/operator/k8s/operator/operator.yaml
@@ -0,0 +1,61 @@
+## Required to use an external/persistent keystore, otherwise a keystore using
+## self-signed certificates will be generated
+# ---
+# apiVersion: v1
+# kind: Secret
+# metadata:
+#   name:  gerrit-operator-ssl
+#   namespace: gerrit-operator
+# data:
+#   keystore.jks: # base64-encoded Java keystore
+#   keystore.password: # base64-encoded Java keystore password
+# type: Opaque
+
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: gerrit-operator
+  namespace: gerrit-operator
+spec:
+  selector:
+    matchLabels:
+      app: gerrit-operator
+  template:
+    metadata:
+      labels:
+        app: gerrit-operator
+    spec:
+      serviceAccountName: gerrit-operator
+      containers:
+      - name: operator
+        image: k8sgerrit/gerrit-operator
+        imagePullPolicy: Always
+        env:
+        - name: NAMESPACE
+          valueFrom:
+            fieldRef:
+              fieldPath: metadata.namespace
+        ports:
+        - containerPort: 80
+        readinessProbe:
+          httpGet:
+            path: /health
+            port: 8080
+            scheme: HTTPS
+          initialDelaySeconds: 1
+        livenessProbe:
+          httpGet:
+            path: /health
+            port: 8080
+            scheme: HTTPS
+          initialDelaySeconds: 30
+      ## Only required, if an external/persistent keystore is being used.
+      #   volumeMounts:
+      #   - name: ssl
+      #     readOnly: true
+      #     mountPath: /operator
+      # volumes:
+      # - name: ssl
+      #   secret:
+      #     secretName: gerrit-operator-ssl
diff --git a/operator/k8s/operator/rbac.yaml b/operator/k8s/operator/rbac.yaml
new file mode 100644
index 0000000..201cce7
--- /dev/null
+++ b/operator/k8s/operator/rbac.yaml
@@ -0,0 +1,87 @@
+apiVersion: v1
+kind: ServiceAccount
+metadata:
+  name: gerrit-operator
+  namespace: gerrit-operator
+
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRoleBinding
+metadata:
+  name: gerrit-operator-admin
+subjects:
+- kind: ServiceAccount
+  name: gerrit-operator
+  namespace: gerrit-operator
+roleRef:
+  kind: ClusterRole
+  name: gerrit-operator
+  apiGroup: ""
+
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+  name: gerrit-operator
+rules:
+- apiGroups:
+  - "batch"
+  resources:
+  - cronjobs
+  verbs:
+  - '*'
+- apiGroups:
+  - "apps"
+  resources:
+  - statefulsets
+  - deployments
+  verbs:
+  - '*'
+- apiGroups:
+  - ""
+  resources:
+  - configmaps
+  - persistentvolumeclaims
+  - secrets
+  - services
+  verbs:
+  - '*'
+- apiGroups:
+  - "storage.k8s.io"
+  resources:
+  - storageclasses
+  verbs:
+  - 'get'
+  - 'list'
+- apiGroups:
+  - "apiextensions.k8s.io"
+  resources:
+  - customresourcedefinitions
+  verbs:
+  - '*'
+- apiGroups:
+  - "networking.k8s.io"
+  resources:
+  - ingresses
+  verbs:
+  - '*'
+- apiGroups:
+  - "gerritoperator.google.com"
+  resources:
+  - '*'
+  verbs:
+  - '*'
+- apiGroups:
+  - "networking.istio.io"
+  resources:
+  - "gateways"
+  - "virtualservices"
+  - "destinationrules"
+  verbs:
+  - '*'
+- apiGroups:
+  - "admissionregistration.k8s.io"
+  resources:
+  - 'validatingwebhookconfigurations'
+  verbs:
+  - '*'
diff --git a/operator/pom.xml b/operator/pom.xml
index 6c713bd..d422fb0 100644
--- a/operator/pom.xml
+++ b/operator/pom.xml
@@ -221,6 +221,12 @@
 			<version>4.8.0</version>
 			<scope>test</scope>
 		</dependency>
+		<dependency>
+			<groupId>io.fabric8</groupId>
+			<artifactId>kubernetes-server-mock</artifactId>
+			<version>${fabric8.version}</version>
+			<scope>test</scope>
+		</dependency>
 	</dependencies>
 
 	<build>
diff --git a/operator/src/main/java/com/google/gerrit/k8s/operator/Main.java b/operator/src/main/java/com/google/gerrit/k8s/operator/Main.java
index cfba467..8fc1428 100644
--- a/operator/src/main/java/com/google/gerrit/k8s/operator/Main.java
+++ b/operator/src/main/java/com/google/gerrit/k8s/operator/Main.java
@@ -14,6 +14,7 @@
 
 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;
@@ -23,7 +24,8 @@
 
   public static void main(String[] args) throws Exception {
     Injector injector = Guice.createInjector(Stage.PRODUCTION, new OperatorModule());
-    injector.getInstance(GerritOperator.class).start();
     injector.getInstance(HttpServer.class).start();
+    injector.getInstance(ValidationWebhookConfigs.class).apply();
+    injector.getInstance(GerritOperator.class).start();
   }
 }
diff --git a/operator/src/main/java/com/google/gerrit/k8s/operator/OperatorModule.java b/operator/src/main/java/com/google/gerrit/k8s/operator/OperatorModule.java
index 4e78219..5888d1d 100644
--- a/operator/src/main/java/com/google/gerrit/k8s/operator/OperatorModule.java
+++ b/operator/src/main/java/com/google/gerrit/k8s/operator/OperatorModule.java
@@ -14,6 +14,7 @@
 
 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;
@@ -34,6 +35,7 @@
   protected void configure() {
     install(new EnvModule());
     install(new ServerModule());
+    install(new AdmissionWebhookModule());
 
     bind(String.class)
         .annotatedWith(Names.named("Namespace"))
diff --git a/operator/src/main/java/com/google/gerrit/k8s/operator/admission/AbstractValidationWebhookConfigApplier.java b/operator/src/main/java/com/google/gerrit/k8s/operator/admission/AbstractValidationWebhookConfigApplier.java
new file mode 100644
index 0000000..37d7572
--- /dev/null
+++ b/operator/src/main/java/com/google/gerrit/k8s/operator/admission/AbstractValidationWebhookConfigApplier.java
@@ -0,0 +1,111 @@
+// 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 io.fabric8.kubernetes.api.model.admissionregistration.v1.RuleWithOperations;
+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.Base64;
+import java.util.List;
+
+public abstract class AbstractValidationWebhookConfigApplier
+    implements ValidationWebhookConfigApplier {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final KubernetesClient client;
+  private final String namespace;
+  private final KeyStoreProvider keyStoreProvider;
+  private final ValidatingWebhookConfiguration cfg;
+
+  public AbstractValidationWebhookConfigApplier(
+      KubernetesClient client, String namespace, KeyStoreProvider keyStoreProvider) {
+    this.client = client;
+    this.namespace = namespace;
+    this.keyStoreProvider = keyStoreProvider;
+
+    this.cfg = build();
+  }
+
+  abstract String name();
+
+  abstract String webhookPath();
+
+  abstract List<RuleWithOperations> rules();
+
+  private String caBundle()
+      throws CertificateEncodingException, KeyStoreException, NoSuchAlgorithmException,
+          CertificateException, IOException {
+    return Base64.getEncoder().encodeToString(keyStoreProvider.getCertificate().getBytes());
+  }
+
+  @Override
+  public ValidatingWebhookConfiguration build() {
+    try {
+      return new ValidatingWebhookConfigurationBuilder()
+          .withNewMetadata()
+          .withName(name())
+          .endMetadata()
+          .withWebhooks(
+              new ValidatingWebhookBuilder()
+                  .withName(name() + ".validator.google.com")
+                  .withAdmissionReviewVersions("v1", "v1beta1")
+                  .withNewClientConfig()
+                  .withCaBundle(caBundle())
+                  .withNewService()
+                  .withName(SERVICE_NAME)
+                  .withNamespace(namespace)
+                  .withPath(webhookPath())
+                  .withPort(SERVICE_PORT)
+                  .endService()
+                  .endClientConfig()
+                  .withFailurePolicy("Fail")
+                  .withMatchPolicy("Equivalent")
+                  .withRules(rules())
+                  .withTimeoutSeconds(10)
+                  .withSideEffects("None")
+                  .build())
+          .build();
+    } catch (KeyStoreException | NoSuchAlgorithmException | CertificateException | IOException e) {
+      throw new RuntimeException("Failed to deploy ValidationWebhookConfiguration " + name(), e);
+    }
+  }
+
+  @Override
+  public void apply()
+      throws KeyStoreException, NoSuchProviderException, IOException, NoSuchAlgorithmException,
+          CertificateException {
+    logger.atInfo().log("Applying webhook config %s", cfg);
+    client.resource(cfg).createOrReplace();
+  }
+
+  @Override
+  public void delete() {
+    logger.atInfo().log("Deleting webhook config %s", cfg);
+    client.resource(cfg).delete();
+  }
+}
diff --git a/operator/src/main/java/com/google/gerrit/k8s/operator/admission/AdmissionWebhookModule.java b/operator/src/main/java/com/google/gerrit/k8s/operator/admission/AdmissionWebhookModule.java
new file mode 100644
index 0000000..b94f8e8
--- /dev/null
+++ b/operator/src/main/java/com/google/gerrit/k8s/operator/admission/AdmissionWebhookModule.java
@@ -0,0 +1,28 @@
+// 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.inject.AbstractModule;
+import com.google.inject.multibindings.Multibinder;
+
+public class AdmissionWebhookModule extends AbstractModule {
+  public void configure() {
+    Multibinder<ValidationWebhookConfigApplier> vwcAppliers =
+        Multibinder.newSetBinder(binder(), ValidationWebhookConfigApplier.class);
+    vwcAppliers.addBinding().to(GitGcValidationWebhookConfigApplier.class);
+
+    bind(ValidationWebhookConfigs.class);
+  }
+}
diff --git a/operator/src/main/java/com/google/gerrit/k8s/operator/admission/GitGcValidationWebhookConfigApplier.java b/operator/src/main/java/com/google/gerrit/k8s/operator/admission/GitGcValidationWebhookConfigApplier.java
new file mode 100644
index 0000000..f92d875
--- /dev/null
+++ b/operator/src/main/java/com/google/gerrit/k8s/operator/admission/GitGcValidationWebhookConfigApplier.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.admission;
+
+import com.google.gerrit.k8s.operator.server.KeyStoreProvider;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+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.client.KubernetesClient;
+import java.util.List;
+
+@Singleton
+public class GitGcValidationWebhookConfigApplier extends AbstractValidationWebhookConfigApplier {
+
+  @Inject
+  public GitGcValidationWebhookConfigApplier(
+      KubernetesClient client,
+      @Named("Namespace") String namespace,
+      KeyStoreProvider keyStoreProvider) {
+    super(client, namespace, keyStoreProvider);
+  }
+
+  @Override
+  String name() {
+    return "gitgc";
+  }
+
+  @Override
+  String webhookPath() {
+    return "/admission/gitgc";
+  }
+
+  @Override
+  List<RuleWithOperations> rules() {
+    return List.of(
+        new RuleWithOperationsBuilder()
+            .withApiGroups("gerritoperator.google.com")
+            .withApiVersions("*")
+            .withOperations("CREATE", "UPDATE")
+            .withResources("gitgcs")
+            .withScope("*")
+            .build());
+  }
+}
diff --git a/operator/src/main/java/com/google/gerrit/k8s/operator/admission/ValidationWebhookConfigApplier.java b/operator/src/main/java/com/google/gerrit/k8s/operator/admission/ValidationWebhookConfigApplier.java
new file mode 100644
index 0000000..b8cc44f
--- /dev/null
+++ b/operator/src/main/java/com/google/gerrit/k8s/operator/admission/ValidationWebhookConfigApplier.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.admission;
+
+import io.fabric8.kubernetes.api.model.admissionregistration.v1.ValidatingWebhookConfiguration;
+
+public interface ValidationWebhookConfigApplier {
+  /** Builds the ValidatingWebhookConfiguration */
+  ValidatingWebhookConfiguration build() throws Exception;
+  /** Applies the ValidatingWebhookConfiguration to the cluster */
+  void apply() throws Exception;
+  /** Deletes the ValidatingWebhookConfiguration to the cluster */
+  void delete();
+}
diff --git a/operator/src/main/java/com/google/gerrit/k8s/operator/admission/ValidationWebhookConfigs.java b/operator/src/main/java/com/google/gerrit/k8s/operator/admission/ValidationWebhookConfigs.java
new file mode 100644
index 0000000..a63aa0c
--- /dev/null
+++ b/operator/src/main/java/com/google/gerrit/k8s/operator/admission/ValidationWebhookConfigs.java
@@ -0,0 +1,50 @@
+// 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.LifecycleManager;
+import com.google.inject.Inject;
+import java.util.Set;
+
+public class ValidationWebhookConfigs {
+
+  private final Set<ValidationWebhookConfigApplier> configAppliers;
+
+  @Inject
+  public ValidationWebhookConfigs(
+      LifecycleManager lifecycleManager, Set<ValidationWebhookConfigApplier> configAppliers) {
+    this.configAppliers = configAppliers;
+    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/operator/src/main/java/com/google/gerrit/k8s/operator/gitgc/GitGarbageCollectionReconciler.java b/operator/src/main/java/com/google/gerrit/k8s/operator/gitgc/GitGarbageCollectionReconciler.java
index 9fc7012..c32fa34 100644
--- a/operator/src/main/java/com/google/gerrit/k8s/operator/gitgc/GitGarbageCollectionReconciler.java
+++ b/operator/src/main/java/com/google/gerrit/k8s/operator/gitgc/GitGarbageCollectionReconciler.java
@@ -33,11 +33,8 @@
 import io.javaoperatorsdk.operator.processing.event.source.EventSource;
 import io.javaoperatorsdk.operator.processing.event.source.SecondaryToPrimaryMapper;
 import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource;
-import java.util.Collection;
-import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
-import java.util.Set;
 import java.util.stream.Collectors;
 
 @Singleton
@@ -98,7 +95,6 @@
   @Override
   public UpdateControl<GitGarbageCollection> reconcile(
       GitGarbageCollection gitGc, Context<GitGarbageCollection> context) {
-    validateGitGCProjectList(gitGc);
     if (gitGc.getSpec().getProjects().isEmpty()) {
       gitGc = excludeProjectsHandledSeparately(gitGc);
     }
@@ -136,55 +132,6 @@
     return currentGitGc;
   }
 
-  private void validateGitGCProjectList(GitGarbageCollection gitGc) {
-    List<GitGarbageCollection> gitGcs =
-        client
-            .resources(GitGarbageCollection.class)
-            .inNamespace(gitGc.getMetadata().getNamespace())
-            .list()
-            .getItems();
-    Set<String> projects = gitGc.getSpec().getProjects();
-
-    gitGcs =
-        gitGcs.stream()
-            .filter(gc -> !gc.getMetadata().getUid().equals(gitGc.getMetadata().getUid()))
-            .collect(Collectors.toList());
-    logger.atFine().log("Detected GitGcs: %s", gitGcs);
-    List<GitGarbageCollection> allProjectGcs =
-        gitGcs.stream().filter(gc -> gc.getStatus().isReplicateAll()).collect(Collectors.toList());
-    if (!allProjectGcs.isEmpty() && projects.isEmpty()) {
-      throw new GitGarbageCollectionConflictException(
-          "Multiple Git GC jobs working on all projects are not allowed.");
-    }
-
-    Set<String> projectsWithExistingGC =
-        gitGcs.stream()
-            .map(gc -> gc.getSpec().getProjects())
-            .flatMap(Collection::stream)
-            .collect(Collectors.toSet());
-    Set<String> projectsIntercept = getIntercept(projects, projectsWithExistingGC);
-    if (projectsIntercept.isEmpty()) {
-      return;
-    }
-    logger.atFine().log("Found conflicting projects: %s", projectsIntercept);
-
-    if (gitGcs.stream()
-        .filter(gc -> !getIntercept(projects, gc.getSpec().getProjects()).isEmpty())
-        .allMatch(gc -> gc.getStatus().getState().equals(GitGcState.CONFLICT))) {
-      logger.atFine().log("All other GitGcs are marked as conflicting. Activating %s", gitGc);
-      return;
-    }
-    logger.atFine().log("%s will be marked as conflicting", gitGc);
-    throw new GitGarbageCollectionConflictException(projectsIntercept);
-  }
-
-  private Set<String> getIntercept(Set<String> set1, Set<String> set2) {
-    Set<String> intercept = new HashSet<>();
-    intercept.addAll(set1);
-    intercept.retainAll(set2);
-    return intercept;
-  }
-
   @Override
   public ErrorStatusUpdateControl<GitGarbageCollection> updateErrorStatus(
       GitGarbageCollection gitGc, Context<GitGarbageCollection> context, Exception e) {
diff --git a/operator/src/main/java/com/google/gerrit/k8s/operator/server/AbstractKeyStoreProvider.java b/operator/src/main/java/com/google/gerrit/k8s/operator/server/AbstractKeyStoreProvider.java
new file mode 100644
index 0000000..3b71e89
--- /dev/null
+++ b/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/operator/src/main/java/com/google/gerrit/k8s/operator/server/AdmissionWebhookServlet.java b/operator/src/main/java/com/google/gerrit/k8s/operator/server/AdmissionWebhookServlet.java
new file mode 100644
index 0000000..59f8d89
--- /dev/null
+++ b/operator/src/main/java/com/google/gerrit/k8s/operator/server/AdmissionWebhookServlet.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.server;
+
+import jakarta.servlet.http.HttpServlet;
+
+public abstract class AdmissionWebhookServlet extends HttpServlet {
+  private static final long serialVersionUID = 1L;
+
+  public abstract String getName();
+}
diff --git a/operator/src/main/java/com/google/gerrit/k8s/operator/server/FileSystemKeyStoreProvider.java b/operator/src/main/java/com/google/gerrit/k8s/operator/server/FileSystemKeyStoreProvider.java
index fe2ef88..a56da7f 100644
--- a/operator/src/main/java/com/google/gerrit/k8s/operator/server/FileSystemKeyStoreProvider.java
+++ b/operator/src/main/java/com/google/gerrit/k8s/operator/server/FileSystemKeyStoreProvider.java
@@ -20,7 +20,7 @@
 import java.nio.file.Path;
 
 @Singleton
-public class FileSystemKeyStoreProvider implements KeyStoreProvider {
+public class FileSystemKeyStoreProvider extends AbstractKeyStoreProvider {
   static final String KEYSTORE_PATH = "/operator/keystore.jks";
   static final String KEYSTORE_PWD_FILE = "/operator/keystore.password";
 
diff --git a/operator/src/main/java/com/google/gerrit/k8s/operator/server/GeneratedKeyStoreProvider.java b/operator/src/main/java/com/google/gerrit/k8s/operator/server/GeneratedKeyStoreProvider.java
index ba4f7c4..d96204d 100644
--- a/operator/src/main/java/com/google/gerrit/k8s/operator/server/GeneratedKeyStoreProvider.java
+++ b/operator/src/main/java/com/google/gerrit/k8s/operator/server/GeneratedKeyStoreProvider.java
@@ -50,9 +50,8 @@
 import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
 
 @Singleton
-public class GeneratedKeyStoreProvider implements KeyStoreProvider {
+public class GeneratedKeyStoreProvider extends AbstractKeyStoreProvider {
   private static final Path KEYSTORE_PATH = Path.of("/tmp/keystore.jks");
-  private static final String ALIAS = "operator";
 
   private final String namespace;
   private final String password;
@@ -120,7 +119,7 @@
 
       KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType());
       keyStore.load(null, null);
-      keyStore.setKeyEntry(ALIAS, keyPair.getPrivate(), password.toCharArray(), chain);
+      keyStore.setKeyEntry(getAlias(), keyPair.getPrivate(), password.toCharArray(), chain);
       keyStore.store(fos, password.toCharArray());
     } catch (IOException
         | NoSuchAlgorithmException
diff --git a/operator/src/main/java/com/google/gerrit/k8s/operator/server/GitGcAdmissionWebhook.java b/operator/src/main/java/com/google/gerrit/k8s/operator/server/GitGcAdmissionWebhook.java
new file mode 100644
index 0000000..38686be
--- /dev/null
+++ b/operator/src/main/java/com/google/gerrit/k8s/operator/server/GitGcAdmissionWebhook.java
@@ -0,0 +1,127 @@
+// 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.fasterxml.jackson.databind.ObjectMapper;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.k8s.operator.gitgc.GitGarbageCollection;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import io.fabric8.kubernetes.api.model.Status;
+import io.fabric8.kubernetes.api.model.StatusBuilder;
+import io.fabric8.kubernetes.api.model.admission.v1.AdmissionResponseBuilder;
+import io.fabric8.kubernetes.api.model.admission.v1.AdmissionReview;
+import io.fabric8.kubernetes.client.KubernetesClient;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import java.io.IOException;
+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 AdmissionWebhookServlet {
+  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 void doPost(HttpServletRequest request, HttpServletResponse response) throws IOException {
+    ObjectMapper objectMapper = new ObjectMapper();
+    AdmissionReview admissionReq =
+        objectMapper.readValue(request.getInputStream(), AdmissionReview.class);
+
+    logger.atFine().log("GitGc admission request received: %s", admissionReq.toString());
+
+    response.setContentType("application/json");
+    AdmissionResponseBuilder admissionRespBuilder =
+        new AdmissionResponseBuilder().withUid(admissionReq.getRequest().getUid());
+    Status validationStatus =
+        validateGitGCProjectList((GitGarbageCollection) 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(
+        "GitGc admission request responded with %s", admissionReq.getResponse().toString());
+  }
+
+  private Status validateGitGCProjectList(GitGarbageCollection gitGc) {
+    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";
+  }
+}
diff --git a/operator/src/main/java/com/google/gerrit/k8s/operator/server/HttpServer.java b/operator/src/main/java/com/google/gerrit/k8s/operator/server/HttpServer.java
index 10956d8..e4fb2b2 100644
--- a/operator/src/main/java/com/google/gerrit/k8s/operator/server/HttpServer.java
+++ b/operator/src/main/java/com/google/gerrit/k8s/operator/server/HttpServer.java
@@ -16,6 +16,7 @@
 
 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;
@@ -23,6 +24,7 @@
 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
@@ -33,10 +35,13 @@
 
   private final Server server = new Server();
   private final KeyStoreProvider keyStoreProvider;
+  private final Set<AdmissionWebhookServlet> admissionWebhookServlets;
 
   @Inject
-  public HttpServer(KeyStoreProvider keyStoreProvider) {
+  public HttpServer(
+      KeyStoreProvider keyStoreProvider, Set<AdmissionWebhookServlet> admissionWebhookServlets) {
     this.keyStoreProvider = keyStoreProvider;
+    this.admissionWebhookServlets = admissionWebhookServlets;
   }
 
   public void start() throws Exception {
@@ -56,6 +61,10 @@
     server.setConnectors(new Connector[] {connector});
 
     ServletHandler servletHandler = new ServletHandler();
+    for (AdmissionWebhookServlet servlet : admissionWebhookServlets) {
+      servletHandler.addServletWithMapping(
+          new ServletHolder(servlet), "/admission/" + servlet.getName());
+    }
     servletHandler.addServletWithMapping(HealthcheckServlet.class, "/health");
     server.setHandler(servletHandler);
 
diff --git a/operator/src/main/java/com/google/gerrit/k8s/operator/server/KeyStoreProvider.java b/operator/src/main/java/com/google/gerrit/k8s/operator/server/KeyStoreProvider.java
index de2cf62..c41777f 100644
--- a/operator/src/main/java/com/google/gerrit/k8s/operator/server/KeyStoreProvider.java
+++ b/operator/src/main/java/com/google/gerrit/k8s/operator/server/KeyStoreProvider.java
@@ -16,9 +16,17 @@
 
 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/operator/src/main/java/com/google/gerrit/k8s/operator/server/ServerModule.java b/operator/src/main/java/com/google/gerrit/k8s/operator/server/ServerModule.java
index cc1a3aa..2f9752a 100644
--- a/operator/src/main/java/com/google/gerrit/k8s/operator/server/ServerModule.java
+++ b/operator/src/main/java/com/google/gerrit/k8s/operator/server/ServerModule.java
@@ -17,6 +17,7 @@
 import static com.google.gerrit.k8s.operator.server.FileSystemKeyStoreProvider.KEYSTORE_PATH;
 
 import com.google.inject.AbstractModule;
+import com.google.inject.multibindings.Multibinder;
 import java.io.File;
 
 public class ServerModule extends AbstractModule {
@@ -27,5 +28,8 @@
       bind(KeyStoreProvider.class).to(GeneratedKeyStoreProvider.class);
     }
     bind(HttpServer.class);
+    Multibinder<AdmissionWebhookServlet> admissionWebhookServlets =
+        Multibinder.newSetBinder(binder(), AdmissionWebhookServlet.class);
+    admissionWebhookServlets.addBinding().to(GitGcAdmissionWebhook.class);
   }
 }
diff --git a/operator/src/test/java/com/google/gerrit/k8s/operator/gitgc/GitGarbageCollectionE2E.java b/operator/src/test/java/com/google/gerrit/k8s/operator/gitgc/GitGarbageCollectionE2E.java
index ca22c55..ec98b22 100644
--- a/operator/src/test/java/com/google/gerrit/k8s/operator/gitgc/GitGarbageCollectionE2E.java
+++ b/operator/src/test/java/com/google/gerrit/k8s/operator/gitgc/GitGarbageCollectionE2E.java
@@ -25,7 +25,6 @@
 import static org.junit.jupiter.api.Assertions.assertNull;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.k8s.operator.gitgc.GitGarbageCollectionStatus.GitGcState;
 import com.google.gerrit.k8s.operator.test.AbstractGerritOperatorE2ETest;
 import io.fabric8.kubernetes.api.model.ObjectMetaBuilder;
 import io.fabric8.kubernetes.api.model.batch.v1.CronJob;
@@ -132,45 +131,6 @@
             });
   }
 
-  @Test
-  void testConflictingSelectiveGcFailsBeforeCronJobCreation() throws InterruptedException {
-    Set<String> selectedProjects = Set.of("All-Projects", "test");
-    GitGarbageCollection selectiveGitGc1 = createSelectiveGc("selective-gc-1", selectedProjects);
-
-    logger.atInfo().log("Waiting max 2 minutes for GitGc to be created.");
-    await()
-        .atMost(2, MINUTES)
-        .untilAsserted(
-            () -> {
-              assertGitGcCreation(selectiveGitGc1.getMetadata().getName());
-              assertGitGcCronJobCreation(selectiveGitGc1.getMetadata().getName());
-            });
-
-    GitGarbageCollection selectiveGitGc2 = createSelectiveGc("selective-gc-2", selectedProjects);
-    logger.atInfo().log("Waiting max 2 minutes for conflicting GitGc to be created.");
-    await()
-        .atMost(2, MINUTES)
-        .untilAsserted(
-            () -> {
-              GitGarbageCollection updatedSelectiveGitGc =
-                  client
-                      .resources(GitGarbageCollection.class)
-                      .inNamespace(operator.getNamespace())
-                      .withName(selectiveGitGc2.getMetadata().getName())
-                      .get();
-              assert updatedSelectiveGitGc.getStatus().getState().equals(GitGcState.CONFLICT);
-            });
-    CronJob cronJob =
-        client
-            .batch()
-            .v1()
-            .cronjobs()
-            .inNamespace(operator.getNamespace())
-            .withName("selective-gc-2")
-            .get();
-    assertNull(cronJob);
-  }
-
   private GitGarbageCollection createCompleteGc() {
     GitGarbageCollection gitGc = new GitGarbageCollection();
     gitGc.setMetadata(
diff --git a/operator/src/test/java/com/google/gerrit/k8s/operator/server/GitGcAdmissionWebhookTest.java b/operator/src/test/java/com/google/gerrit/k8s/operator/server/GitGcAdmissionWebhookTest.java
new file mode 100644
index 0000000..fdf33d2
--- /dev/null
+++ b/operator/src/test/java/com/google/gerrit/k8s/operator/server/GitGcAdmissionWebhookTest.java
@@ -0,0 +1,251 @@
+// 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.gitgc.GitGarbageCollection;
+import com.google.gerrit.k8s.operator.gitgc.GitGarbageCollectionSpec;
+import com.google.gerrit.k8s.operator.test.TestAdmissionWebhookServer;
+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/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/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/operator/src/test/java/com/google/gerrit/k8s/operator/test/TestAdmissionWebhookServer.java b/operator/src/test/java/com/google/gerrit/k8s/operator/test/TestAdmissionWebhookServer.java
new file mode 100644
index 0000000..c520077
--- /dev/null
+++ b/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.getName());
+  }
+
+  public void stop() throws Exception {
+    server.stop();
+  }
+}