jerryshao commented on code in PR #13724:
URL: https://github.com/apache/gravitino/pull/13724#discussion_r4245163599


##########
plugins/k8s-job-executor/src/main/java/org/apache/gravitino/job/k8s/K8sClientUtils.java:
##########
@@ -0,0 +1,272 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 org.apache.gravitino.job.k8s;
+
+import com.google.common.base.Preconditions;
+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.fabric8.kubernetes.client.OAuthTokenProvider;
+import io.fabric8.kubernetes.client.utils.Serialization;
+import java.io.File;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.attribute.FileTime;
+import java.util.List;
+import java.util.Map;
+import javax.annotation.Nullable;
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Creates the Kubernetes client of the job cluster from the job executor 
configurations. */
+public final class K8sClientUtils {
+
+  /**
+   * An {@link OAuthTokenProvider} that reads the token from a file, and reads 
it again whenever the
+   * file changes, so that a rotated token is picked up without restarting the 
server.
+   */
+  static final class FileTokenProvider implements OAuthTokenProvider {
+
+    private final Path tokenFile;
+
+    private FileTime lastModified;
+
+    private String token;
+
+    private boolean readFailureLogged;
+
+    FileTokenProvider(Path tokenFile) {
+      this.tokenFile = tokenFile;
+    }
+
+    @Override
+    public synchronized String getToken() {
+      try {
+        FileTime modified = Files.getLastModifiedTime(tokenFile);
+        if (token == null || !modified.equals(lastModified)) {
+          String content = new String(Files.readAllBytes(tokenFile), 
StandardCharsets.UTF_8).trim();
+          if (StringUtils.isNotBlank(content)) {
+            token = content;
+            lastModified = modified;
+          } else if (token == null) {
+            throw new IllegalStateException("The token file " + tokenFile + " 
is empty");
+          } else {
+            // The file is being rewritten, keep the token until the new one 
is there. Remember
+            // this version of the file, so that it is neither read nor 
reported again.
+            lastModified = modified;

Review Comment:
   [Important] Remembering the modification time of an empty token file can pin 
a stale token until the file changes again.
   
   The empty-read branch now advances `lastModified`, so the next call skips 
the read at line 68. If the rotating writer truncates the file and writes the 
new token inside the same modification-time tick - a file system with a coarse 
timestamp resolution (NFS, CIFS, ext3), or simply a fast rewrite - the provider 
has already recorded that tick while the file was empty, so it keeps serving 
the token read before until the file changes again, which may be the next 
rotation hours later. That token is expired by then, every request gets a 401, 
and nothing re-reads the file. Before this commit the provider kept re-reading 
and picked the new token up; the repeated WARN was the problem Copilot raised, 
not the token handling.
   
   The read-failure branch three lines below solves the same problem without 
that window: the `readFailureLogged` flag suppresses the repeated log while the 
file is still read every time. The same shape here, e.g. an `emptyLogged` flag 
cleared on a successful read and no write to `lastModified`, keeps the log 
quiet with no stale-token window. Re-reading a few bytes per request is cheaper 
than pinning an expired token.
   
   `testTokenRotation` cannot catch this: it bumps the modification time by 1-2 
s between writes precisely to be robust on "file systems with a coarse 
resolution" (TestK8sClientUtils.java:210, 216), which is the case where this 
bites.
   
   Verified by: running a standalone javac replica of `getToken()` in this 
session over the sequence token-1 at mtime M0, empty at M1, token-2 still at 
M1. With this line the provider returns token-1 with token-2 on disk; without 
it, token-2.



##########
plugins/k8s-job-executor/src/main/java/org/apache/gravitino/job/k8s/K8sClientUtils.java:
##########
@@ -0,0 +1,272 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 org.apache.gravitino.job.k8s;
+
+import com.google.common.base.Preconditions;
+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.fabric8.kubernetes.client.OAuthTokenProvider;
+import io.fabric8.kubernetes.client.utils.Serialization;
+import java.io.File;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.attribute.FileTime;
+import java.util.List;
+import java.util.Map;
+import javax.annotation.Nullable;
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Creates the Kubernetes client of the job cluster from the job executor 
configurations. */
+public final class K8sClientUtils {
+
+  /**
+   * An {@link OAuthTokenProvider} that reads the token from a file, and reads 
it again whenever the
+   * file changes, so that a rotated token is picked up without restarting the 
server.
+   */
+  static final class FileTokenProvider implements OAuthTokenProvider {
+
+    private final Path tokenFile;
+
+    private FileTime lastModified;
+
+    private String token;
+
+    private boolean readFailureLogged;
+
+    FileTokenProvider(Path tokenFile) {
+      this.tokenFile = tokenFile;
+    }
+
+    @Override
+    public synchronized String getToken() {
+      try {
+        FileTime modified = Files.getLastModifiedTime(tokenFile);
+        if (token == null || !modified.equals(lastModified)) {
+          String content = new String(Files.readAllBytes(tokenFile), 
StandardCharsets.UTF_8).trim();
+          if (StringUtils.isNotBlank(content)) {
+            token = content;
+            lastModified = modified;
+          } else if (token == null) {
+            throw new IllegalStateException("The token file " + tokenFile + " 
is empty");
+          } else {
+            // The file is being rewritten, keep the token until the new one 
is there. Remember
+            // this version of the file, so that it is neither read nor 
reported again.
+            lastModified = modified;
+            LOG.warn("The token file {} is empty, keeping the token read 
before", tokenFile);
+          }
+        }
+      } catch (IOException e) {
+        if (token == null) {
+          throw new UncheckedIOException("Failed to read the token file " + 
tokenFile, e);
+        }
+        // For example, the file is being replaced, keep the token until the 
new one is there. It
+        // is checked again on every request, so only the first failure in a 
row is reported.
+        if (!readFailureLogged) {
+          LOG.warn("Failed to read the token file {}, keeping the token read 
before", tokenFile, e);
+          readFailureLogged = true;
+        }

Review Comment:
   [Nit] A token file that stays unreadable is now reported once and then 
serves a stale token silently.
   
   `readFailureLogged` is cleared only by a successful read at line 94, so if 
the file is deleted or its permissions change for good, there is a single WARN 
and then nothing, while every request keeps using a token that will expire. 
Logging again at an interval, or raising the severity after a number of 
consecutive failures, would keep the condition visible without the spam the 
flag avoids.
   
   Verified by: reading this branch and line 94 in this run.



##########
plugins/k8s-job-executor/src/main/java/org/apache/gravitino/job/k8s/K8sClientUtils.java:
##########
@@ -0,0 +1,272 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 org.apache.gravitino.job.k8s;
+
+import com.google.common.base.Preconditions;
+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.fabric8.kubernetes.client.OAuthTokenProvider;
+import io.fabric8.kubernetes.client.utils.Serialization;
+import java.io.File;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.attribute.FileTime;
+import java.util.List;
+import java.util.Map;
+import javax.annotation.Nullable;
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Creates the Kubernetes client of the job cluster from the job executor 
configurations. */
+public final class K8sClientUtils {
+
+  /**
+   * An {@link OAuthTokenProvider} that reads the token from a file, and reads 
it again whenever the
+   * file changes, so that a rotated token is picked up without restarting the 
server.
+   */
+  static final class FileTokenProvider implements OAuthTokenProvider {
+
+    private final Path tokenFile;
+
+    private FileTime lastModified;
+
+    private String token;
+
+    private boolean readFailureLogged;
+
+    FileTokenProvider(Path tokenFile) {
+      this.tokenFile = tokenFile;
+    }
+
+    @Override
+    public synchronized String getToken() {
+      try {
+        FileTime modified = Files.getLastModifiedTime(tokenFile);
+        if (token == null || !modified.equals(lastModified)) {
+          String content = new String(Files.readAllBytes(tokenFile), 
StandardCharsets.UTF_8).trim();
+          if (StringUtils.isNotBlank(content)) {
+            token = content;
+            lastModified = modified;
+          } else if (token == null) {
+            throw new IllegalStateException("The token file " + tokenFile + " 
is empty");
+          } else {
+            // The file is being rewritten, keep the token until the new one 
is there. Remember
+            // this version of the file, so that it is neither read nor 
reported again.
+            lastModified = modified;
+            LOG.warn("The token file {} is empty, keeping the token read 
before", tokenFile);
+          }
+        }
+      } catch (IOException e) {
+        if (token == null) {
+          throw new UncheckedIOException("Failed to read the token file " + 
tokenFile, e);
+        }
+        // For example, the file is being replaced, keep the token until the 
new one is there. It
+        // is checked again on every request, so only the first failure in a 
row is reported.
+        if (!readFailureLogged) {
+          LOG.warn("Failed to read the token file {}, keeping the token read 
before", tokenFile, e);
+          readFailureLogged = true;
+        }
+        return token;
+      }
+      readFailureLogged = false;
+      return token;
+    }
+  }
+
+  private static final Logger LOG = 
LoggerFactory.getLogger(K8sClientUtils.class);
+
+  private K8sClientUtils() {}
+
+  /**
+   * Creates the Kubernetes client of the job cluster.
+   *
+   * @param configs the job executor configurations
+   * @return the Kubernetes client
+   */
+  public static KubernetesClient createClient(K8sJobExecutorConfigs configs) {
+    return new 
KubernetesClientBuilder().withConfig(createClientConfig(configs)).build();
+  }
+
+  /**
+   * Creates the Kubernetes client configuration of the job cluster. It is, by 
precedence:
+   *
+   * <ul>
+   *   <li>the API server URL, with its CA certificate and token file, if the 
master URL is set;
+   *   <li>the given kubeconfig file and context, if the kubeconfig is set;
+   *   <li>fabric8's default lookup otherwise, that is the {@code KUBECONFIG} 
environment variable
+   *       or {@code ~/.kube/config} with the given context, then the 
in-cluster service account.
+   * </ul>
+   *
+   * <p>In every case the default namespace of the client is the namespace of 
the job executor
+   * configurations, rather than the one of the kubeconfig context or of the 
pod Gravitino runs in.
+   *
+   * @param configs the job executor configurations
+   * @return the Kubernetes client configuration
+   * @throws IllegalArgumentException if a configured file doesn't exist, or 
the kubeconfig has no
+   *     usable context
+   */
+  static Config createClientConfig(K8sJobExecutorConfigs configs) {
+    if (configs.masterUrl() != null) {
+      ConfigBuilder builder = new 
ConfigBuilder(Config.empty()).withMasterUrl(configs.masterUrl());
+      if (configs.caCertFile() != null) {
+        checkFileExists(configs.caCertFile(), 
K8sJobExecutorConfigs.CA_CERT_FILE);
+        builder.withCaCertFile(configs.caCertFile());
+      }
+      if (configs.tokenFile() != null) {
+        checkFileExists(configs.tokenFile(), K8sJobExecutorConfigs.TOKEN_FILE);
+        builder.withOauthTokenProvider(new 
FileTokenProvider(Paths.get(configs.tokenFile())));
+      }
+      return builder.withNamespace(configs.namespace()).build();
+    }
+
+    Config config;
+    if (configs.kubeconfig() != null) {
+      checkFileExists(configs.kubeconfig(), K8sJobExecutorConfigs.KUBECONFIG);
+      String server = getKubeconfigServer(configs);
+      config = Config.fromKubeconfig(configs.context(), new 
File(configs.kubeconfig()));
+      // The client reads the file itself, so make sure it ended up with the 
server found above,
+      // whatever it does with a kubeconfig that is malformed or changed in 
between.
+      Preconditions.checkArgument(
+          
normalizeServer(server).equals(normalizeServer(config.getMasterUrl())),
+          "The kubeconfig %s set by %s selects the server %s, but the client 
would use %s",
+          configs.kubeconfig(),
+          K8sJobExecutorConfigs.KUBECONFIG,
+          server,
+          config.getMasterUrl());
+    } else {
+      config = Config.autoConfigure(configs.context());
+    }
+
+    // The client falls back to the current context if the given one doesn't 
exist, which would
+    // silently run the jobs in another cluster.
+    String context =
+        config.getCurrentContext() == null ? null : 
config.getCurrentContext().getName();
+    Preconditions.checkArgument(
+        configs.context() == null || configs.context().equals(context),
+        "The context %s set by %s doesn't exist in the kubeconfig",
+        configs.context(),
+        K8sJobExecutorConfigs.CONTEXT);
+    config.setNamespace(configs.namespace());

Review Comment:
   [Question] Should an unset `namespace` keep the namespace of the pod rather 
than becoming `default`?
   
   `namespace` defaults to `default` (K8sJobExecutorConfigs.java:183), so this 
line and line 142 set `default` on the client whenever the operator configured 
none. In the fabric8-default mode that overrides what `Config.autoConfigure` 
read from the service account's namespace file (Config.java:282, 
`tryNamespaceFromPath`), so a Gravitino pod running in namespace `gravitino` 
would create its job resources in `default`. That precedence is right when the 
value was set explicitly, which is what I asked for last round; it is the unset 
case I am unsure about, where the pod's own namespace looks like the friendlier 
default. Overriding only when the key is present would give both. Fine as is if 
the intent is that the jobs' namespace is always explicit - the javadoc at 
lines 123-124 does say so.
   
   Verified by: reading `Config.autoConfigure` and `tryNamespaceFromPath` in 
the fabric8 7.2.0 sources, and `testNamespace`, which covers the kubeconfig and 
master URL modes but not this one.



##########
plugins/k8s-job-executor/src/main/java/org/apache/gravitino/job/k8s/K8sClientUtils.java:
##########
@@ -0,0 +1,272 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 org.apache.gravitino.job.k8s;
+
+import com.google.common.base.Preconditions;
+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.fabric8.kubernetes.client.OAuthTokenProvider;
+import io.fabric8.kubernetes.client.utils.Serialization;
+import java.io.File;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.attribute.FileTime;
+import java.util.List;
+import java.util.Map;
+import javax.annotation.Nullable;
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Creates the Kubernetes client of the job cluster from the job executor 
configurations. */
+public final class K8sClientUtils {
+
+  /**
+   * An {@link OAuthTokenProvider} that reads the token from a file, and reads 
it again whenever the
+   * file changes, so that a rotated token is picked up without restarting the 
server.
+   */
+  static final class FileTokenProvider implements OAuthTokenProvider {
+
+    private final Path tokenFile;
+
+    private FileTime lastModified;
+
+    private String token;
+
+    private boolean readFailureLogged;
+
+    FileTokenProvider(Path tokenFile) {
+      this.tokenFile = tokenFile;
+    }
+
+    @Override
+    public synchronized String getToken() {
+      try {
+        FileTime modified = Files.getLastModifiedTime(tokenFile);
+        if (token == null || !modified.equals(lastModified)) {
+          String content = new String(Files.readAllBytes(tokenFile), 
StandardCharsets.UTF_8).trim();
+          if (StringUtils.isNotBlank(content)) {
+            token = content;
+            lastModified = modified;
+          } else if (token == null) {
+            throw new IllegalStateException("The token file " + tokenFile + " 
is empty");
+          } else {
+            // The file is being rewritten, keep the token until the new one 
is there. Remember
+            // this version of the file, so that it is neither read nor 
reported again.
+            lastModified = modified;
+            LOG.warn("The token file {} is empty, keeping the token read 
before", tokenFile);
+          }
+        }
+      } catch (IOException e) {
+        if (token == null) {
+          throw new UncheckedIOException("Failed to read the token file " + 
tokenFile, e);
+        }
+        // For example, the file is being replaced, keep the token until the 
new one is there. It
+        // is checked again on every request, so only the first failure in a 
row is reported.
+        if (!readFailureLogged) {
+          LOG.warn("Failed to read the token file {}, keeping the token read 
before", tokenFile, e);
+          readFailureLogged = true;
+        }
+        return token;
+      }
+      readFailureLogged = false;
+      return token;
+    }
+  }
+
+  private static final Logger LOG = 
LoggerFactory.getLogger(K8sClientUtils.class);
+
+  private K8sClientUtils() {}
+
+  /**
+   * Creates the Kubernetes client of the job cluster.
+   *
+   * @param configs the job executor configurations
+   * @return the Kubernetes client
+   */
+  public static KubernetesClient createClient(K8sJobExecutorConfigs configs) {
+    return new 
KubernetesClientBuilder().withConfig(createClientConfig(configs)).build();
+  }
+
+  /**
+   * Creates the Kubernetes client configuration of the job cluster. It is, by 
precedence:
+   *
+   * <ul>
+   *   <li>the API server URL, with its CA certificate and token file, if the 
master URL is set;
+   *   <li>the given kubeconfig file and context, if the kubeconfig is set;
+   *   <li>fabric8's default lookup otherwise, that is the {@code KUBECONFIG} 
environment variable
+   *       or {@code ~/.kube/config} with the given context, then the 
in-cluster service account.
+   * </ul>
+   *
+   * <p>In every case the default namespace of the client is the namespace of 
the job executor
+   * configurations, rather than the one of the kubeconfig context or of the 
pod Gravitino runs in.
+   *
+   * @param configs the job executor configurations
+   * @return the Kubernetes client configuration
+   * @throws IllegalArgumentException if a configured file doesn't exist, or 
the kubeconfig has no
+   *     usable context
+   */
+  static Config createClientConfig(K8sJobExecutorConfigs configs) {
+    if (configs.masterUrl() != null) {
+      ConfigBuilder builder = new 
ConfigBuilder(Config.empty()).withMasterUrl(configs.masterUrl());
+      if (configs.caCertFile() != null) {
+        checkFileExists(configs.caCertFile(), 
K8sJobExecutorConfigs.CA_CERT_FILE);
+        builder.withCaCertFile(configs.caCertFile());
+      }
+      if (configs.tokenFile() != null) {
+        checkFileExists(configs.tokenFile(), K8sJobExecutorConfigs.TOKEN_FILE);
+        builder.withOauthTokenProvider(new 
FileTokenProvider(Paths.get(configs.tokenFile())));
+      }
+      return builder.withNamespace(configs.namespace()).build();
+    }
+
+    Config config;
+    if (configs.kubeconfig() != null) {
+      checkFileExists(configs.kubeconfig(), K8sJobExecutorConfigs.KUBECONFIG);
+      String server = getKubeconfigServer(configs);
+      config = Config.fromKubeconfig(configs.context(), new 
File(configs.kubeconfig()));
+      // The client reads the file itself, so make sure it ended up with the 
server found above,
+      // whatever it does with a kubeconfig that is malformed or changed in 
between.
+      Preconditions.checkArgument(
+          
normalizeServer(server).equals(normalizeServer(config.getMasterUrl())),
+          "The kubeconfig %s set by %s selects the server %s, but the client 
would use %s",
+          configs.kubeconfig(),
+          K8sJobExecutorConfigs.KUBECONFIG,
+          server,
+          config.getMasterUrl());
+    } else {
+      config = Config.autoConfigure(configs.context());
+    }
+
+    // The client falls back to the current context if the given one doesn't 
exist, which would
+    // silently run the jobs in another cluster.
+    String context =
+        config.getCurrentContext() == null ? null : 
config.getCurrentContext().getName();
+    Preconditions.checkArgument(
+        configs.context() == null || configs.context().equals(context),
+        "The context %s set by %s doesn't exist in the kubeconfig",
+        configs.context(),
+        K8sJobExecutorConfigs.CONTEXT);
+    config.setNamespace(configs.namespace());
+    return config;
+  }
+
+  /**
+   * Returns the API server of the cluster that the kubeconfig selects, and 
fails if it selects
+   * none. Otherwise the client keeps its default API server, the one of the 
cluster Gravitino runs
+   * in, and would silently run the jobs there. The default API server itself 
isn't rejected, as a
+   * kubeconfig may well select it.
+   */
+  private static String getKubeconfigServer(K8sJobExecutorConfigs configs) {
+    Map<String, Object> kubeconfig;
+    try {
+      kubeconfig =
+          getMap(
+              Serialization.unmarshal(
+                  new String(
+                      Files.readAllBytes(Paths.get(configs.kubeconfig())), 
StandardCharsets.UTF_8),
+                  Map.class));

Review Comment:
   [Nit] This walk over the raw YAML could use fabric8's own parser, the one 
the client uses on the next line.
   
   `Config.fromKubeconfig(String, File)` parses the file with 
`KubeConfigUtils.parseConfig(File)` (Config.java:790-791), a public method that 
returns the typed `io.fabric8.kubernetes.api.model.Config`. Reusing it would 
replace `getNamedEntry`, `getMap` and the `Map` navigation - about 45 lines - 
with typed lookups over `getContexts()` and `getClusters()`, parse the file 
exactly as the client does, and keep the two from disagreeing on an unusual 
kubeconfig: with duplicate entries of one name, fabric8's merge keeps the last 
(KubeConfigUtils.java:240-272) while `getNamedEntry` returns the first, which 
would fail the server comparison rather than agree with the client.
   
   Verified by: reading KubeConfigUtils.java:68-88 and 240-272 in the fabric8 
7.2.0 sources fetched in this run.



##########
plugins/k8s-job-executor/src/main/java/org/apache/gravitino/job/k8s/K8sClientUtils.java:
##########
@@ -0,0 +1,272 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 org.apache.gravitino.job.k8s;
+
+import com.google.common.base.Preconditions;
+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.fabric8.kubernetes.client.OAuthTokenProvider;
+import io.fabric8.kubernetes.client.utils.Serialization;
+import java.io.File;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.attribute.FileTime;
+import java.util.List;
+import java.util.Map;
+import javax.annotation.Nullable;
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Creates the Kubernetes client of the job cluster from the job executor 
configurations. */
+public final class K8sClientUtils {
+
+  /**
+   * An {@link OAuthTokenProvider} that reads the token from a file, and reads 
it again whenever the
+   * file changes, so that a rotated token is picked up without restarting the 
server.
+   */
+  static final class FileTokenProvider implements OAuthTokenProvider {
+
+    private final Path tokenFile;
+
+    private FileTime lastModified;
+
+    private String token;
+
+    private boolean readFailureLogged;
+
+    FileTokenProvider(Path tokenFile) {
+      this.tokenFile = tokenFile;
+    }
+
+    @Override
+    public synchronized String getToken() {
+      try {
+        FileTime modified = Files.getLastModifiedTime(tokenFile);
+        if (token == null || !modified.equals(lastModified)) {
+          String content = new String(Files.readAllBytes(tokenFile), 
StandardCharsets.UTF_8).trim();
+          if (StringUtils.isNotBlank(content)) {
+            token = content;
+            lastModified = modified;
+          } else if (token == null) {
+            throw new IllegalStateException("The token file " + tokenFile + " 
is empty");
+          } else {
+            // The file is being rewritten, keep the token until the new one 
is there. Remember
+            // this version of the file, so that it is neither read nor 
reported again.
+            lastModified = modified;
+            LOG.warn("The token file {} is empty, keeping the token read 
before", tokenFile);
+          }
+        }
+      } catch (IOException e) {
+        if (token == null) {
+          throw new UncheckedIOException("Failed to read the token file " + 
tokenFile, e);
+        }
+        // For example, the file is being replaced, keep the token until the 
new one is there. It
+        // is checked again on every request, so only the first failure in a 
row is reported.
+        if (!readFailureLogged) {
+          LOG.warn("Failed to read the token file {}, keeping the token read 
before", tokenFile, e);
+          readFailureLogged = true;
+        }
+        return token;
+      }
+      readFailureLogged = false;
+      return token;
+    }
+  }
+
+  private static final Logger LOG = 
LoggerFactory.getLogger(K8sClientUtils.class);
+
+  private K8sClientUtils() {}
+
+  /**
+   * Creates the Kubernetes client of the job cluster.
+   *
+   * @param configs the job executor configurations
+   * @return the Kubernetes client
+   */
+  public static KubernetesClient createClient(K8sJobExecutorConfigs configs) {
+    return new 
KubernetesClientBuilder().withConfig(createClientConfig(configs)).build();
+  }
+
+  /**
+   * Creates the Kubernetes client configuration of the job cluster. It is, by 
precedence:
+   *
+   * <ul>
+   *   <li>the API server URL, with its CA certificate and token file, if the 
master URL is set;
+   *   <li>the given kubeconfig file and context, if the kubeconfig is set;
+   *   <li>fabric8's default lookup otherwise, that is the {@code KUBECONFIG} 
environment variable
+   *       or {@code ~/.kube/config} with the given context, then the 
in-cluster service account.
+   * </ul>
+   *
+   * <p>In every case the default namespace of the client is the namespace of 
the job executor
+   * configurations, rather than the one of the kubeconfig context or of the 
pod Gravitino runs in.
+   *
+   * @param configs the job executor configurations
+   * @return the Kubernetes client configuration
+   * @throws IllegalArgumentException if a configured file doesn't exist, or 
the kubeconfig has no
+   *     usable context
+   */
+  static Config createClientConfig(K8sJobExecutorConfigs configs) {
+    if (configs.masterUrl() != null) {
+      ConfigBuilder builder = new 
ConfigBuilder(Config.empty()).withMasterUrl(configs.masterUrl());
+      if (configs.caCertFile() != null) {
+        checkFileExists(configs.caCertFile(), 
K8sJobExecutorConfigs.CA_CERT_FILE);
+        builder.withCaCertFile(configs.caCertFile());
+      }
+      if (configs.tokenFile() != null) {
+        checkFileExists(configs.tokenFile(), K8sJobExecutorConfigs.TOKEN_FILE);
+        builder.withOauthTokenProvider(new 
FileTokenProvider(Paths.get(configs.tokenFile())));
+      }
+      return builder.withNamespace(configs.namespace()).build();
+    }
+
+    Config config;
+    if (configs.kubeconfig() != null) {
+      checkFileExists(configs.kubeconfig(), K8sJobExecutorConfigs.KUBECONFIG);
+      String server = getKubeconfigServer(configs);
+      config = Config.fromKubeconfig(configs.context(), new 
File(configs.kubeconfig()));
+      // The client reads the file itself, so make sure it ended up with the 
server found above,
+      // whatever it does with a kubeconfig that is malformed or changed in 
between.
+      Preconditions.checkArgument(
+          
normalizeServer(server).equals(normalizeServer(config.getMasterUrl())),
+          "The kubeconfig %s set by %s selects the server %s, but the client 
would use %s",
+          configs.kubeconfig(),
+          K8sJobExecutorConfigs.KUBECONFIG,
+          server,
+          config.getMasterUrl());
+    } else {
+      config = Config.autoConfigure(configs.context());
+    }
+
+    // The client falls back to the current context if the given one doesn't 
exist, which would
+    // silently run the jobs in another cluster.
+    String context =
+        config.getCurrentContext() == null ? null : 
config.getCurrentContext().getName();
+    Preconditions.checkArgument(
+        configs.context() == null || configs.context().equals(context),
+        "The context %s set by %s doesn't exist in the kubeconfig",
+        configs.context(),
+        K8sJobExecutorConfigs.CONTEXT);
+    config.setNamespace(configs.namespace());
+    return config;
+  }
+
+  /**
+   * Returns the API server of the cluster that the kubeconfig selects, and 
fails if it selects
+   * none. Otherwise the client keeps its default API server, the one of the 
cluster Gravitino runs
+   * in, and would silently run the jobs there. The default API server itself 
isn't rejected, as a
+   * kubeconfig may well select it.
+   */
+  private static String getKubeconfigServer(K8sJobExecutorConfigs configs) {
+    Map<String, Object> kubeconfig;
+    try {
+      kubeconfig =
+          getMap(
+              Serialization.unmarshal(
+                  new String(
+                      Files.readAllBytes(Paths.get(configs.kubeconfig())), 
StandardCharsets.UTF_8),
+                  Map.class));
+    } catch (IOException e) {
+      throw new UncheckedIOException("Failed to read the kubeconfig " + 
configs.kubeconfig(), e);
+    } catch (RuntimeException e) {
+      throw new IllegalArgumentException(
+          String.format(
+              "The kubeconfig %s set by %s can't be parsed",
+              configs.kubeconfig(), K8sJobExecutorConfigs.KUBECONFIG),
+          e);
+    }
+
+    Object contextName =
+        configs.context() != null
+            ? configs.context()
+            : kubeconfig == null ? null : kubeconfig.get("current-context");
+    Preconditions.checkArgument(
+        contextName instanceof String && StringUtils.isNotBlank((String) 
contextName),
+        "The kubeconfig %s set by %s has no current context, set its 
current-context or %s",
+        configs.kubeconfig(),
+        K8sJobExecutorConfigs.KUBECONFIG,
+        K8sJobExecutorConfigs.CONTEXT);
+
+    Map<String, Object> context = getNamedEntry(kubeconfig, "contexts", 
contextName, "context");
+    Preconditions.checkArgument(
+        context != null,
+        "The context %s doesn't exist in the kubeconfig %s set by %s",
+        contextName,
+        configs.kubeconfig(),
+        K8sJobExecutorConfigs.KUBECONFIG);
+
+    Object clusterName = context.get("cluster");
+    Map<String, Object> cluster = getNamedEntry(kubeconfig, "clusters", 
clusterName, "cluster");
+    Object server = cluster == null ? null : cluster.get("server");
+    Preconditions.checkArgument(
+        server instanceof String && StringUtils.isNotBlank((String) server),
+        "The cluster %s of the context %s has no server in the kubeconfig %s 
set by %s",
+        clusterName,
+        contextName,
+        configs.kubeconfig(),
+        K8sJobExecutorConfigs.KUBECONFIG);
+    return (String) server;
+  }
+
+  /** Returns the URL of an API server in the form the client keeps it, to 
compare two of them. */
+  private static String normalizeServer(String server) {
+    String url = server.trim();
+    if (!url.startsWith("http://";) && !url.startsWith("https://";)) {
+      url = "https://"; + url;
+    }
+    return url.endsWith("/") ? url : url + "/";

Review Comment:
   [Important] This assumes `https`, while the client picks the scheme from a 
live TLS probe, so a kubeconfig whose `server` has no scheme is rejected at 
startup by the check at line 152.
   
   For `server: 10.0.0.1:6443` - legal in a kubeconfig, and what `kubectl 
config set-cluster --server=host:port` writes - `KubeConfigUtils.merge` calls 
`clientConfig.setMasterUrl(cluster.getServer())` (KubeConfigUtils.java:191) 
before it sets the cluster's CA (lines 195-197). `setMasterUrl` runs 
`ensureHttps`, which prefixes `https://` only if 
`SSLUtils.isHttpsAvailable(config)` returns true (Config.java:303-308, 
1141-1144), and that method opens a real HTTPS connection with a 1 s timeout 
using an SSL context built from a config that has no CA yet 
(SSLUtils.java:59-71). With a private CA, or an API server not reachable at 
startup, it returns false and the client ends up with `http://10.0.0.1:6443/`. 
`normalizeServer` returns `https://10.0.0.1:6443/`, the precondition fails, and 
the server refuses to start with "selects the server 10.0.0.1:6443, but the 
client would use http://10.0.0.1:6443/";, which reads like a contradiction. Two 
smaller mismatches in the same helper: the prefix test
  is case-sensitive while fabric8 lowercases it, so `HTTPS://host` gets a 
second prefix, and this trims the value while fabric8 does not.
   
   Comparing the two with the scheme stripped, or deriving the expected value 
through fabric8 itself (`new 
ConfigBuilder(Config.empty()).withMasterUrl(server).build().getMasterUrl()`), 
keeps the check without re-implementing the normalization. A fixture with a 
scheme-less server would cover it.
   
   Verified by: reading the fabric8 7.2.0 sources jar fetched in this run 
(Config.java:303-308 and 1141-1144, SSLUtils.java:59-71, 
KubeConfigUtils.java:191-197); no fixture in TestK8sClientUtils uses a 
scheme-less server.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to