support CA certificates for incoming replication

Replication from hosts using non-publicly-trusted certificates (e.g.
internal GitHub Enterprise instances) failed with SSL verification
errors.

If the secret referenced by secretRef contains a ca.crt key, the
operator now mounts it at /home/gerrit/ca.crt, same as the existing
.netrc mount. The fetch-job script checks for this file at runtime and
sets GIT_SSL_CAINFO.

Change-Id: I8e81f45ad674fe0c9b539b5fd578809431d2b31b
diff --git a/container-images/fetch-job/README.md b/container-images/fetch-job/README.md
index 9d217fc..55a4939 100644
--- a/container-images/fetch-job/README.md
+++ b/container-images/fetch-job/README.md
@@ -35,5 +35,22 @@
       - ^refs/heads/excluded-*
 ```
 
-You will need to mount the credentials used to authenticate with remote servers
-to the container, e.g. by providing a .netrc file at `home/gerrit/.netrc`.
+## Authentication
+
+Mount a `.netrc` file at `/home/gerrit/.netrc` to provide credentials for
+authenticating with remote servers.
+
+## TLS/SSL
+
+If the remote server uses a certificate signed by a non-publicly-trusted CA
+(e.g. an internal CA), provide the CA certificate in PEM format at
+`/home/gerrit/ca.crt`. The fetch-job will automatically use it for all fetch
+operations if present.
+
+When using the Gerrit operator, both the `.netrc` and `ca.crt` files can be
+provided via the `secretRef` field of the `IncomingReplicationTask` resource.
+If the referenced secret contains a `.netrc` key and/or a `ca.crt` key, the
+operator will mount them at the expected paths automatically.
+
+Adding a custom `ca.crt` will use that file for SSL verification, ignoring
+existing system CA bundles.
\ No newline at end of file
diff --git a/operator/src/main/java/com/google/gerrit/k8s/operator/tasks/incomingrepl/dependent/IncomingReplicationTaskCronJob.java b/operator/src/main/java/com/google/gerrit/k8s/operator/tasks/incomingrepl/dependent/IncomingReplicationTaskCronJob.java
index f833818..18174a7 100644
--- a/operator/src/main/java/com/google/gerrit/k8s/operator/tasks/incomingrepl/dependent/IncomingReplicationTaskCronJob.java
+++ b/operator/src/main/java/com/google/gerrit/k8s/operator/tasks/incomingrepl/dependent/IncomingReplicationTaskCronJob.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.k8s.operator.util.CRUDReconcileAddKubernetesDependentResource;
 import io.fabric8.kubernetes.api.model.Container;
 import io.fabric8.kubernetes.api.model.ContainerBuilder;
+import io.fabric8.kubernetes.api.model.EnvVarBuilder;
 import io.fabric8.kubernetes.api.model.Secret;
 import io.fabric8.kubernetes.api.model.Volume;
 import io.fabric8.kubernetes.api.model.VolumeBuilder;
@@ -43,6 +44,7 @@
 public class IncomingReplicationTaskCronJob
     extends CRUDReconcileAddKubernetesDependentResource<CronJob, IncomingReplicationTask> {
   private static final String CONFIGMAP_VOLUME_NAME = "incoming-repl-config";
+  private static final String CA_MOUNT_PATH = "/home/gerrit/ca.crt";
 
   public IncomingReplicationTaskCronJob() {
     super(CronJob.class);
@@ -127,23 +129,40 @@
 
   private Container buildTaskContainer(
       IncomingReplicationTask incomingReplTask, Context<IncomingReplicationTask> context) {
+    return new ContainerBuilder()
+        .withName("incoming-replication")
+        .withSecurityContext(GerritSecurityContext.forContainer())
+        .withImagePullPolicy(incomingReplTask.getSpec().getContainerImages().getImagePullPolicy())
+        .withImage(
+            incomingReplTask
+                .getSpec()
+                .getContainerImages()
+                .getGerritImages()
+                .getFullImageName("fetch-job"))
+        .withResources(incomingReplTask.getSpec().getResources())
+        .withVolumeMounts(getVolumeMounts(incomingReplTask, context))
+        .withEnv(getEnvVars(incomingReplTask, context))
+        .build();
+  }
 
-    ContainerBuilder taskContainerBuilder =
-        new ContainerBuilder()
-            .withName("incoming-replication")
-            .withSecurityContext(GerritSecurityContext.forContainer())
-            .withImagePullPolicy(
-                incomingReplTask.getSpec().getContainerImages().getImagePullPolicy())
-            .withImage(
-                incomingReplTask
-                    .getSpec()
-                    .getContainerImages()
-                    .getGerritImages()
-                    .getFullImageName("fetch-job"))
-            .withResources(incomingReplTask.getSpec().getResources())
-            .withVolumeMounts(getVolumeMounts(incomingReplTask, context));
-
-    return taskContainerBuilder.build();
+  private List<io.fabric8.kubernetes.api.model.EnvVar> getEnvVars(
+      IncomingReplicationTask incomingReplTask, Context<IncomingReplicationTask> context) {
+    List<io.fabric8.kubernetes.api.model.EnvVar> envVars = new ArrayList<>();
+    String secretRef = incomingReplTask.getSpec().getSecretRef();
+    if (secretRef != null && !secretRef.isBlank()) {
+      Secret secret =
+          context
+              .getClient()
+              .resources(Secret.class)
+              .inNamespace(incomingReplTask.getMetadata().getNamespace())
+              .withName(secretRef)
+              .get();
+      if (secret != null && secret.getData().containsKey("ca.crt")) {
+        envVars.add(
+            new EnvVarBuilder().withName("GIT_SSL_CAINFO").withValue(CA_MOUNT_PATH).build());
+      }
+    }
+    return envVars;
   }
 
   private List<VolumeMount> getVolumeMounts(
@@ -178,13 +197,23 @@
               .inNamespace(incomingReplTask.getMetadata().getNamespace())
               .withName(secretRef)
               .get();
-      if (secret != null && secret.getData().containsKey(".netrc")) {
-        volumeMounts.add(
-            new VolumeMountBuilder()
-                .withName(secretRef)
-                .withSubPath(".netrc")
-                .withMountPath("/home/gerrit/.netrc")
-                .build());
+      if (secret != null) {
+        if (secret.getData().containsKey(".netrc")) {
+          volumeMounts.add(
+              new VolumeMountBuilder()
+                  .withName(secretRef)
+                  .withSubPath(".netrc")
+                  .withMountPath("/home/gerrit/.netrc")
+                  .build());
+        }
+        if (secret.getData().containsKey("ca.crt")) {
+          volumeMounts.add(
+              new VolumeMountBuilder()
+                  .withName(secretRef)
+                  .withSubPath("ca.crt")
+                  .withMountPath(CA_MOUNT_PATH)
+                  .build());
+        }
       }
     }
     return volumeMounts;
diff --git a/operator/src/test/java/com/google/gerrit/k8s/operator/tasks/incomingrepl/dependent/IncomingReplicationTaskTest.java b/operator/src/test/java/com/google/gerrit/k8s/operator/tasks/incomingrepl/dependent/IncomingReplicationTaskTest.java
index 4c9cf7e..7024a67 100644
--- a/operator/src/test/java/com/google/gerrit/k8s/operator/tasks/incomingrepl/dependent/IncomingReplicationTaskTest.java
+++ b/operator/src/test/java/com/google/gerrit/k8s/operator/tasks/incomingrepl/dependent/IncomingReplicationTaskTest.java
@@ -38,6 +38,7 @@
 import java.util.stream.Stream;
 import org.junit.Rule;
 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;
 import org.junit.jupiter.params.ParameterizedTest;
@@ -107,6 +108,78 @@
         .isEqualTo(ReconcilerUtils.loadYaml(ConfigMap.class, this.getClass(), expectedConfigMap));
   }
 
+  @Test
+  public void caCertMountedWhenPresentInSecret() throws Exception {
+    IncomingReplicationTask incomingReplTask =
+        ReconcilerUtils.loadYaml(
+            IncomingReplicationTask.class, this.getClass(), "../incomingrepltask.yaml");
+
+    // Use a different secret name to avoid conflicting with the parameterized test mock
+    String testSecretName = "test-secret-with-ca-cert";
+    incomingReplTask.getSpec().setSecretRef(testSecretName);
+
+    Secret testSecret =
+        new SecretBuilder()
+            .withNewMetadata()
+            .withName(testSecretName)
+            .withNamespace(incomingReplTask.getMetadata().getNamespace())
+            .endMetadata()
+            .withData(
+                Map.of(
+                    ".netrc", "c2VjcmV0Cg==",
+                    "ca.crt", "Y2VydGlmaWNhdGUK"))
+            .build();
+
+    kubernetesServer
+        .expect()
+        .get()
+        .withPath(
+            String.format(
+                "/api/v1/namespaces/%s/secrets/%s",
+                incomingReplTask.getMetadata().getNamespace(), testSecretName))
+        .andReturn(HttpURLConnection.HTTP_OK, testSecret)
+        .always();
+
+    Context<IncomingReplicationTask> context =
+        getContext(new IncomingReplicationTaskReconciler(), incomingReplTask);
+    CronJob cronJob = new IncomingReplicationTaskCronJob().desired(incomingReplTask, context);
+
+    boolean hasCaCertMount =
+        cronJob
+            .getSpec()
+            .getJobTemplate()
+            .getSpec()
+            .getTemplate()
+            .getSpec()
+            .getContainers()
+            .get(0)
+            .getVolumeMounts()
+            .stream()
+            .anyMatch(
+                vm ->
+                    vm.getMountPath().equals("/home/gerrit/ca.crt")
+                        && vm.getSubPath().equals("ca.crt"));
+
+    boolean hasGitSslCaInfo =
+        cronJob
+            .getSpec()
+            .getJobTemplate()
+            .getSpec()
+            .getTemplate()
+            .getSpec()
+            .getContainers()
+            .get(0)
+            .getEnv()
+            .stream()
+            .anyMatch(
+                env ->
+                    env.getName().equals("GIT_SSL_CAINFO")
+                        && env.getValue().equals("/home/gerrit/ca.crt"));
+
+    assertThat(hasGitSslCaInfo).isTrue();
+    assertThat(hasCaCertMount).isTrue();
+  }
+
   private Context<IncomingReplicationTask> getContext(
       Reconciler<IncomingReplicationTask> reconciler, IncomingReplicationTask primary) {
     Controller<IncomingReplicationTask> controller =