This is an automated email from the ASF dual-hosted git repository.

jerryshao pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git


The following commit(s) were added to refs/heads/main by this push:
     new 48572b5eb7 [#13613] feat(k8s-job-executor): Add the k8s job executor 
module with its configurations and Kubernetes connection (part 1/3) (#13724)
48572b5eb7 is described below

commit 48572b5eb7a9a0ae4d62ce171242a39374904d70
Author: Jerry Shao <[email protected]>
AuthorDate: Tue Oct 13 14:25:25 2026 +0800

    [#13613] feat(k8s-job-executor): Add the k8s job executor module with its 
configurations and Kubernetes connection (part 1/3) (#13724)
    
    ### What changes were proposed in this pull request?
    
    This is part 1 of 3 of #13613, which adds a job executor named `k8s`
    that runs Spark jobs on Kubernetes. It was split from #13700 to keep
    each part reviewable.
    
    This part adds the module `plugins/k8s-job-executor` and the classes
    that don't depend on how jobs run:
    
    - **Build**: a shaded jar with fabric8 kubernetes-client 7.2.0 and its
    JDK HTTP client. Jackson and the YAML libraries are relocated to
    `org.apache.gravitino.k8s.shaded`. `LICENSE.bin` lists the bundled
    dependencies.
    - **`K8sJobExecutorConfigs`**: the configurations of the executor, under
    `gravitino.jobExecutor.k8s.`.
    - **`K8sClientUtils`**: creates the Kubernetes client from a kubeconfig
    and context, or from an API server URL with a CA certificate and a token
    file. The token file is read again when it changes. Without either,
    fabric8's default lookup applies, which covers running inside the job
    cluster.
    - **`K8sJobExecutionId`**: the job execution id,
    `<cluster>/<kind>/<namespace>/<name>`.
    - **`K8sJobResourceUtils`**: the names, labels and annotations of the
    Kubernetes resources created for the jobs.
    
    Nothing uses these classes yet. Part 2 (#13725) adds the
    SparkApplication mapping, and part 3 (#13700) adds the executor itself.
    
    ### Why are the changes needed?
    
    The only built-in job executor, `local`, runs jobs as child processes of
    the Gravitino server, which isn't suitable for production. #13554 adds
    an executor that runs jobs on Kubernetes and keeps all the job state
    there.
    
    Part of: #13613
    
    ### Does this PR introduce _any_ user-facing change?
    
    No. The configurations only take effect once the executor is added in
    part 3 and registered in #13616.
    
    ### How was this patch tested?
    
    Unit tests in `plugins/k8s-job-executor`.
    
    🤖 Generated with [Claude Code](https://claude.com/claude-code)
    
    ---------
    
    Co-authored-by: Claude Opus 5.5 <[email protected]>
---
 LICENSE.bin                                        |   5 +
 gradle/libs.versions.toml                          |   5 +
 plugins/k8s-job-executor/build.gradle.kts          |  85 ++++
 .../apache/gravitino/job/k8s/K8sClientUtils.java   | 288 ++++++++++++
 .../gravitino/job/k8s/K8sJobExecutionId.java       | 132 ++++++
 .../gravitino/job/k8s/K8sJobExecutorConfigs.java   | 519 +++++++++++++++++++++
 .../gravitino/job/k8s/K8sJobResourceUtils.java     | 106 +++++
 .../gravitino/job/k8s/K8sKubeconfigUtils.java      | 115 +++++
 .../gravitino/job/k8s/TestK8sClientUtils.java      | 451 ++++++++++++++++++
 .../gravitino/job/k8s/TestK8sJobExecutionId.java   |  76 +++
 .../job/k8s/TestK8sJobExecutorConfigs.java         | 197 ++++++++
 .../gravitino/job/k8s/TestK8sJobResourceUtils.java |  85 ++++
 settings.gradle.kts                                |   1 +
 13 files changed, 2065 insertions(+)

diff --git a/LICENSE.bin b/LICENSE.bin
index a95017bd21..9da550578c 100644
--- a/LICENSE.bin
+++ b/LICENSE.bin
@@ -222,6 +222,7 @@
    Jackson Core
    Jackson Databind
    Jackson Data Mapper
+   Jackson Dataformat YAML
    Jackson Datatype Guava
    Jackson Datatype JDK8
    Jackson Datatype Joda
@@ -356,6 +357,7 @@
    jmxutils
    SQLite JDBC
    SnakeYAML
+   SnakeYAML Engine
    StAX API
    Tomcat Jasper Compiler
    Apache Xerces Java
@@ -406,6 +408,9 @@
    IPAddress
    Aliyun SDK OSS
    QLExpress
+   Fabric8 Kubernetes Client
+   Fabric8 Kubernetes Model
+   Fabric8 zjsonpatch
 
    This product bundles various third-party components also under the
    Apache Software Foundation License 1.1
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index c491cc5134..cfe5bb7ce6 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -55,6 +55,8 @@ httpclient5 = "5.4.4"
 httpcore5 = "5.3.4"
 micrometer-core = "1.12.2"
 mockserver = "5.15.0"
+# 7.3.x moves to Jackson 2.19, which isn't audited in 
dev/release/maven/dependencies.txt yet.
+fabric8-kubernetes = "7.2.0"
 commons-configuration2 = "2.10.1"
 commons-csv = "1.12.0"
 commons-lang3 = "3.14.0"
@@ -271,6 +273,9 @@ micrometer-core = { group = "io.micrometer", name = 
"micrometer-core", version.r
 
 mockserver-netty = { group = "org.mock-server", name = "mockserver-netty", 
version.ref = "mockserver" }
 mockserver-client-java = { group = "org.mock-server", name = 
"mockserver-client-java", version.ref = "mockserver" }
+fabric8-kubernetes-client = { group = "io.fabric8", name = 
"kubernetes-client", version.ref = "fabric8-kubernetes" }
+fabric8-kubernetes-httpclient-jdk = { group = "io.fabric8", name = 
"kubernetes-httpclient-jdk", version.ref = "fabric8-kubernetes" }
+fabric8-kubernetes-server-mock = { group = "io.fabric8", name = 
"kubernetes-server-mock", version.ref = "fabric8-kubernetes" }
 commons-csv = { group = "org.apache.commons", name = "commons-csv", 
version.ref = "commons-csv" }
 commons-lang = { group = "commons-lang", name = "commons-lang", version.ref = 
"commons-lang" }
 commons-lang3 = { group = "org.apache.commons", name = "commons-lang3", 
version.ref = "commons-lang3" }
diff --git a/plugins/k8s-job-executor/build.gradle.kts 
b/plugins/k8s-job-executor/build.gradle.kts
new file mode 100644
index 0000000000..0005b4832a
--- /dev/null
+++ b/plugins/k8s-job-executor/build.gradle.kts
@@ -0,0 +1,85 @@
+/*
+ * 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.
+ */
+import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
+
+plugins {
+  id("java")
+  id("idea")
+  alias(libs.plugins.shadow)
+}
+
+dependencies {
+  annotationProcessor(libs.lombok)
+
+  // Provided by the Gravitino server at runtime, never bundled into the 
shaded jar.
+  compileOnly(project(":api"))
+  compileOnly(project(":common"))
+  compileOnly(project(":core"))
+  compileOnly(libs.commons.lang3)
+  compileOnly(libs.guava)
+  compileOnly(libs.lombok)
+  compileOnly(libs.slf4j.api)
+
+  // Bundled and relocated, as fabric8 needs a newer Jackson than the 
Gravitino server ships.
+  implementation(libs.fabric8.kubernetes.client) {
+    exclude(group = "io.fabric8", module = "kubernetes-httpclient-okhttp")
+    exclude(group = "io.fabric8", module = "kubernetes-httpclient-vertx")
+  }
+  implementation(libs.fabric8.kubernetes.httpclient.jdk)
+
+  testImplementation(project(":api"))
+  testImplementation(project(":common"))
+  testImplementation(project(":core"))
+  testImplementation(libs.commons.lang3)
+  testImplementation(libs.fabric8.kubernetes.server.mock)
+  testImplementation(libs.guava)
+  testImplementation(libs.junit.jupiter.api)
+  testImplementation(libs.junit.jupiter.params)
+
+  testRuntimeOnly(libs.junit.jupiter.engine)
+}
+
+tasks.withType(ShadowJar::class.java) {
+  isZip64 = true
+  configurations = listOf(project.configurations.runtimeClasspath.get())
+  archiveClassifier.set("")
+  // fabric8 loads its HTTP client and resource handlers through ServiceLoader.
+  mergeServiceFiles()
+
+  exclude("module-info.class")
+  exclude("META-INF/versions/**")
+  // Leftovers of the fabric8 build, of no use at runtime.
+  exclude("META-INF/jandex.idx")
+  exclude("manifest.vm")
+
+  dependencies {
+    exclude(dependency("org.slf4j:.*"))
+  }
+
+  // Use a prefix of its own: the built-in jobs jar already relocates Jackson 
under
+  // org.apache.gravitino.shaded.
+  relocate("com.fasterxml.jackson", 
"org.apache.gravitino.k8s.shaded.com.fasterxml.jackson")
+  relocate("org.snakeyaml.engine", 
"org.apache.gravitino.k8s.shaded.org.snakeyaml.engine")
+  relocate("org.yaml.snakeyaml", 
"org.apache.gravitino.k8s.shaded.org.yaml.snakeyaml")
+}
+
+tasks.jar {
+  dependsOn(tasks.named("shadowJar"))
+  archiveClassifier.set("empty")
+}
diff --git 
a/plugins/k8s-job-executor/src/main/java/org/apache/gravitino/job/k8s/K8sClientUtils.java
 
b/plugins/k8s-job-executor/src/main/java/org/apache/gravitino/job/k8s/K8sClientUtils.java
new file mode 100644
index 0000000000..2e765c8a10
--- /dev/null
+++ 
b/plugins/k8s-job-executor/src/main/java/org/apache/gravitino/job/k8s/K8sClientUtils.java
@@ -0,0 +1,288 @@
+/*
+ * 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.api.model.NamedContext;
+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 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.concurrent.TimeUnit;
+import java.util.regex.Pattern;
+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.
+   *
+   * <p>It doesn't refresh the token itself, whoever provides the file does. 
For a file mounted from
+   * a Secret, that is whatever updates the Secret, and the kubelet then 
updates the mounted file,
+   * unless it is mounted with a {@code subPath}. For a projected service 
account token, the kubelet
+   * rotates it by itself.
+   */
+  static final class FileTokenProvider implements OAuthTokenProvider {
+
+    // How often a token file that stays empty or unreadable is reported.
+    private static final long FAILURE_LOG_INTERVAL_NANOS = 
TimeUnit.MINUTES.toNanos(5);
+
+    private final Path tokenFile;
+
+    private FileTime lastModified;
+
+    private String token;
+
+    private boolean failing;
+
+    private long failureLoggedAtNanos;
+
+    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.isBlank(content)) {
+            if (token == null) {
+              throw new IllegalStateException("The token file " + tokenFile + 
" is empty");
+            }
+            // The file is being rewritten. Its modification time isn't 
remembered: the new token
+            // may be written within the same tick of the file system's clock, 
and would then
+            // never be read.
+            return keepToken("is empty", null);
+          }
+          token = content;
+          lastModified = modified;
+        }
+      } catch (IOException e) {
+        if (token == null) {
+          throw new UncheckedIOException("Failed to read the token file " + 
tokenFile, e);
+        }
+        // For example, the file is being replaced.
+        return keepToken("can't be read", e);
+      }
+      failing = false;
+      return token;
+    }
+
+    /**
+     * Keeps the token read before while the file has no token, checking the 
file again on the next
+     * request. The problem is reported when it starts, and then at an 
interval rather than on every
+     * request, so that a file that stays broken remains visible without 
flooding the log.
+     */
+    private String keepToken(String problem, @Nullable IOException cause) {
+      long now = System.nanoTime();
+      if (!failing || now - failureLoggedAtNanos >= 
FAILURE_LOG_INTERVAL_NANOS) {
+        LOG.warn(
+            "The token file {} {}, keeping the token read before. Requests 
will fail once that"
+                + " token expires",
+            tokenFile,
+            problem,
+            cause);
+        failing = true;
+        failureLoggedAtNanos = now;
+      }
+      return token;
+    }
+  }
+
+  private static final Logger LOG = 
LoggerFactory.getLogger(K8sClientUtils.class);
+
+  private static final Pattern SERVER_SCHEME =
+      Pattern.compile("^https?://", Pattern.CASE_INSENSITIVE);
+
+  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.
+   * The connection timeout, the request timeout and the number of request 
retries are the ones of
+   * the job executor configurations too. They bound how long a call to the 
client waits while the
+   * job cluster is down: each of the 1 + retries attempts waits for the 
connection timeout if the
+   * API server can't be reached, and for the request timeout if it accepts 
the request but never
+   * answers. With the defaults that is about 15 and 30 seconds.
+   *
+   * @param configs the job executor configurations
+   * @return the Kubernetes client configuration
+   * @throws IllegalArgumentException if a configured file doesn't exist, the 
token file has no
+   *     token, the kubeconfig has no context that selects a cluster with a 
server, or the default
+   *     lookup finds no cluster at all
+   */
+  static Config createClientConfig(K8sJobExecutorConfigs configs) {
+    Config config = createConnectionConfig(configs);
+    config.setNamespace(configs.namespace());
+    // The client would otherwise try a request again 10 times, which holds a 
status query or a
+    // job submission for about two minutes while the job cluster can't be 
reached.
+    config.setConnectionTimeout(configs.connectionTimeoutMs());
+    config.setRequestTimeout(configs.requestTimeoutMs());
+    config.setRequestRetryBackoffLimit(configs.requestRetries());
+    return config;
+  }
+
+  private static Config createConnectionConfig(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);
+        FileTokenProvider tokenProvider = new 
FileTokenProvider(Paths.get(configs.tokenFile()));
+        // Read the token once now, so that a file without a token fails the 
startup rather than
+        // the first request.
+        try {
+          tokenProvider.getToken();
+        } catch (IllegalStateException | UncheckedIOException e) {
+          throw new IllegalArgumentException(
+              String.format(
+                  "No token can be read from the file %s set by %s",
+                  configs.tokenFile(), K8sJobExecutorConfigs.TOKEN_FILE),
+              e);
+        }
+        builder.withOauthTokenProvider(tokenProvider);
+      }
+      return builder.build();
+    }
+
+    Config config;
+    if (configs.kubeconfig() != null) {
+      checkFileExists(configs.kubeconfig(), K8sJobExecutorConfigs.KUBECONFIG);
+      File kubeconfig = new File(configs.kubeconfig());
+      String server = K8sKubeconfigUtils.getSelectedServer(kubeconfig, 
configs.context());
+      config = Config.fromKubeconfig(configs.context(), kubeconfig);
+      // The client reads the file itself, so make sure it ended up with the 
server found above.
+      // This is a safety net with no known input that triggers it: it only 
fails if the client
+      // reads a kubeconfig differently from the check above, or the file 
changes in between.
+      Preconditions.checkArgument(
+          getHostAndPath(server).equals(getHostAndPath(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());
+      checkDefaultLookup(config, configs);
+    }
+    return config;
+  }
+
+  /**
+   * Checks the client configuration that the default lookup found. The lookup 
may merge several
+   * kubeconfig files, so they aren't checked one by one like a configured 
kubeconfig. Instead, the
+   * result must not be one of those where the client silently stays on its 
default API server, the
+   * one of the cluster Gravitino runs in, although a kubeconfig was found.
+   */
+  private static void checkDefaultLookup(Config config, K8sJobExecutorConfigs 
configs) {
+    NamedContext current = config.getCurrentContext();
+    String currentName = current == null ? null : current.getName();
+    // The client falls back to the current context if the given one doesn't 
exist. Checked
+    // first, so that the error is about the context that was asked for.
+    Preconditions.checkArgument(
+        configs.context() == null || configs.context().equals(currentName),
+        "The context %s set by %s doesn't exist in the kubeconfig found by the 
default lookup",
+        configs.context(),
+        K8sJobExecutorConfigs.CONTEXT);
+
+    // A kubeconfig with contexts was found, but none of them is selected. 
Without any
+    // kubeconfig, as in a pod, there are no contexts and the service account 
is used.
+    Preconditions.checkArgument(
+        current != null || config.getContexts() == null || 
config.getContexts().isEmpty(),
+        "The kubeconfig found by the default lookup has no current context, 
set its"
+            + " current-context or %s",
+        K8sJobExecutorConfigs.CONTEXT);
+
+    // Both a context that selects a cluster with a server, and the 
environment of a pod, move
+    // the client off its default API server. If it is still there, the lookup 
found neither.
+    boolean foundServer =
+        
!getHostAndPath(Config.DEFAULT_MASTER_URL).equals(getHostAndPath(config.getMasterUrl()));
+    Preconditions.checkArgument(
+        foundServer || current == null,
+        "The context %s of the kubeconfig found by the default lookup selects 
no cluster with a"
+            + " server. Fix that kubeconfig, or set %s or %s. To use the API 
server %s itself,"
+            + " set it by %s",
+        currentName,
+        K8sJobExecutorConfigs.KUBECONFIG,
+        K8sJobExecutorConfigs.MASTER_URL,
+        Config.DEFAULT_MASTER_URL,
+        K8sJobExecutorConfigs.MASTER_URL);
+    Preconditions.checkArgument(
+        foundServer,
+        "No Kubernetes cluster is configured: the default lookup found no 
kubeconfig, and"
+            + " Gravitino doesn't run in a pod. Set %s or %s. To use the API 
server %s itself,"
+            + " set it by %s",
+        K8sJobExecutorConfigs.KUBECONFIG,
+        K8sJobExecutorConfigs.MASTER_URL,
+        Config.DEFAULT_MASTER_URL,
+        K8sJobExecutorConfigs.MASTER_URL);
+  }
+
+  /**
+   * Returns the URL of an API server without its scheme and trailing slashes, 
to compare the server
+   * of a kubeconfig with the one the client uses. The scheme is left out 
because the client picks
+   * it for a server without one, by probing whether the server speaks HTTPS.
+   */
+  private static String getHostAndPath(String server) {
+    return 
SERVER_SCHEME.matcher(server.trim()).replaceFirst("").replaceAll("/+$", "");
+  }
+
+  private static void checkFileExists(String path, String key) {
+    Preconditions.checkArgument(
+        new File(path).isFile(), "The file %s set by %s doesn't exist", path, 
key);
+  }
+}
diff --git 
a/plugins/k8s-job-executor/src/main/java/org/apache/gravitino/job/k8s/K8sJobExecutionId.java
 
b/plugins/k8s-job-executor/src/main/java/org/apache/gravitino/job/k8s/K8sJobExecutionId.java
new file mode 100644
index 0000000000..b42cd89756
--- /dev/null
+++ 
b/plugins/k8s-job-executor/src/main/java/org/apache/gravitino/job/k8s/K8sJobExecutionId.java
@@ -0,0 +1,132 @@
+/*
+ * 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 java.util.Arrays;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.experimental.Accessors;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.gravitino.exceptions.NoSuchJobException;
+
+/**
+ * The job execution id of the {@code k8s} job executor, {@code
+ * <cluster>/<kind>/<namespace>/<name>}. It locates the Kubernetes resource of 
a job: the cluster it
+ * runs in, the kind of the resource, and the resource's namespace and name.
+ */
+@Getter
+@Accessors(fluent = true)
+@EqualsAndHashCode
+@AllArgsConstructor(access = AccessLevel.PRIVATE)
+public final class K8sJobExecutionId {
+
+  /** The kind of the Kubernetes resource a job runs as. */
+  @Getter
+  @Accessors(fluent = true)
+  @AllArgsConstructor(access = AccessLevel.PRIVATE)
+  public enum Kind {
+    /** A Spark job, run as a SparkApplication of the Spark operator. */
+    SPARK_APPLICATION("sparkapp");
+
+    /** The kind in the job execution id. */
+    private final String id;
+
+    /**
+     * Returns the kind of the given id in a job execution id.
+     *
+     * @param id the kind in the job execution id
+     * @return the kind
+     * @throws IllegalArgumentException if the kind is unknown
+     */
+    public static Kind fromId(String id) {
+      return Arrays.stream(values())
+          .filter(kind -> kind.id.equals(id))
+          .findFirst()
+          .orElseThrow(() -> new IllegalArgumentException("Unknown kind of a 
job: " + id));
+    }
+  }
+
+  private static final String SEPARATOR = "/";
+
+  /** The cluster the resource is in. */
+  private final String cluster;
+
+  /** The kind of the resource. */
+  private final Kind kind;
+
+  /** The namespace of the resource. */
+  private final String namespace;
+
+  /** The name of the resource. */
+  private final String name;
+
+  /**
+   * Creates the job execution id of a Kubernetes resource.
+   *
+   * @param cluster the cluster the resource is in
+   * @param kind the kind of the resource
+   * @param namespace the namespace of the resource
+   * @param name the name of the resource
+   * @return the job execution id
+   */
+  public static K8sJobExecutionId of(String cluster, Kind kind, String 
namespace, String name) {
+    Preconditions.checkArgument(kind != null, "kind must not be null");
+    for (String part : new String[] {cluster, namespace, name}) {
+      Preconditions.checkArgument(
+          StringUtils.isNotBlank(part) && !part.contains(SEPARATOR),
+          "Invalid part of a job execution id: %s",
+          part);
+    }
+    return new K8sJobExecutionId(cluster, kind, namespace, name);
+  }
+
+  /**
+   * Parses a job execution id.
+   *
+   * @param jobExecutionId the job execution id
+   * @return the parsed job execution id
+   * @throws NoSuchJobException if the job execution id is malformed or of an 
unknown kind, so no
+   *     such job exists
+   */
+  public static K8sJobExecutionId parse(String jobExecutionId) throws 
NoSuchJobException {
+    String[] parts = 
StringUtils.defaultString(jobExecutionId).split(SEPARATOR, -1);
+    if (parts.length != 4 || StringUtils.isAnyBlank(parts)) {
+      throw new NoSuchJobException(
+          "Invalid job execution id %s of the k8s job executor, expected"
+              + " <cluster>/<kind>/<namespace>/<name>",
+          jobExecutionId);
+    }
+
+    Kind kind;
+    try {
+      kind = Kind.fromId(parts[1]);
+    } catch (IllegalArgumentException e) {
+      throw new NoSuchJobException(e, "Unknown kind %s of job %s", parts[1], 
jobExecutionId);
+    }
+    return new K8sJobExecutionId(parts[0], kind, parts[2], parts[3]);
+  }
+
+  @Override
+  public String toString() {
+    return String.join(SEPARATOR, cluster, kind.id(), namespace, name);
+  }
+}
diff --git 
a/plugins/k8s-job-executor/src/main/java/org/apache/gravitino/job/k8s/K8sJobExecutorConfigs.java
 
b/plugins/k8s-job-executor/src/main/java/org/apache/gravitino/job/k8s/K8sJobExecutorConfigs.java
new file mode 100644
index 0000000000..306322a771
--- /dev/null
+++ 
b/plugins/k8s-job-executor/src/main/java/org/apache/gravitino/job/k8s/K8sJobExecutorConfigs.java
@@ -0,0 +1,519 @@
+/*
+ * 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 com.google.common.collect.ImmutableMap;
+import java.util.Map;
+import java.util.TreeMap;
+import java.util.regex.Pattern;
+import javax.annotation.Nullable;
+import org.apache.commons.lang3.StringUtils;
+
+/**
+ * The configurations of the {@code k8s} job executor, set with the {@code
+ * gravitino.jobExecutor.k8s.} prefix in the Gravitino server configuration.
+ */
+public class K8sJobExecutorConfigs {
+
+  /** The name of the job executor. */
+  public static final String K8S_JOB_EXECUTOR_NAME = "k8s";
+
+  /** The name of the Kubernetes cluster the jobs run in, which is part of the 
job execution id. */
+  public static final String CLUSTER = "cluster";
+
+  /** The default cluster name. */
+  public static final String DEFAULT_CLUSTER = "default";
+
+  /** The kubeconfig file of the job cluster. */
+  public static final String KUBECONFIG = "kubeconfig";
+
+  /** The kubeconfig context to use, the current context if not set. */
+  public static final String CONTEXT = "context";
+
+  /**
+   * The API server URL of the job cluster, an alternative to the kubeconfig. 
It must start with
+   * {@code https://}, or with {@code http://} if no token file is set.
+   */
+  public static final String MASTER_URL = "masterUrl";
+
+  /** The CA certificate file of the API server, used with {@link 
#MASTER_URL}. */
+  public static final String CA_CERT_FILE = "caCertFile";
+
+  /**
+   * The bearer token file to access the API server, used with {@link 
#MASTER_URL}. It is read again
+   * whenever it changes, so the token can be rotated by whoever provides the 
file, for example by
+   * updating the Secret it is mounted from. Gravitino doesn't refresh the 
token itself.
+   */
+  public static final String TOKEN_FILE = "tokenFile";
+
+  /** How long to wait to connect to the API server. */
+  public static final String CONNECTION_TIMEOUT_MS = "connectionTimeoutInMs";
+
+  /** The default connection timeout, 5 seconds. */
+  public static final long DEFAULT_CONNECTION_TIMEOUT_MS = 5_000L;
+
+  /** How long to wait for the response of a request to the API server. */
+  public static final String REQUEST_TIMEOUT_MS = "requestTimeoutInMs";
+
+  /** The default request timeout, 10 seconds. */
+  public static final long DEFAULT_REQUEST_TIMEOUT_MS = 10_000L;
+
+  /**
+   * How many times a failed request to the API server is tried again, for 
example if the server
+   * can't be reached, doesn't answer in time or answers with a server error. 
Each attempt waits for
+   * the timeouts again, so while the job cluster is down a submission, a 
status query or a
+   * cancellation waits up to 1 + retries times the connection timeout if the 
API server can't be
+   * reached, or the request timeout if it never answers.
+   */
+  public static final String REQUEST_RETRIES = "requestRetries";
+
+  /** The default number of request retries. */
+  public static final long DEFAULT_REQUEST_RETRIES = 2L;
+
+  /**
+   * The namespace the jobs run in, unless a job sets its own. It must be set: 
the namespace needs
+   * the permissions and the Spark operator set up for the jobs, so none is 
assumed.
+   */
+  public static final String NAMESPACE = "namespace";
+
+  /** The prefix of the names of the Kubernetes resources created for the 
jobs. */
+  public static final String NAME_PREFIX = "namePrefix";
+
+  /** The default name prefix. */
+  public static final String DEFAULT_NAME_PREFIX = "gravitino-job";
+
+  /** How long the resources listed in a namespace are cached for the job 
status queries. */
+  public static final String STATUS_CACHE_TTL_MS = "statusCacheTtlInMs";
+
+  /** The default status cache TTL, 5 seconds. */
+  public static final long DEFAULT_STATUS_CACHE_TTL_MS = 5_000L;
+
+  /** A job whose resource still has no status after this time is considered 
failed. */
+  public static final String NO_STATUS_TIMEOUT_MS = "noStatusTimeoutInMs";
+
+  /** The default no status timeout, 10 minutes. */
+  public static final long DEFAULT_NO_STATUS_TIMEOUT_MS = 10 * 60 * 1000L;
+
+  /** The Spark image of the driver and executors, the default of a job's 
container image. */
+  public static final String SPARK_IMAGE = "spark.image";
+
+  /** The Spark version of the image, set as the SparkApplication runtime 
version. */
+  public static final String SPARK_VERSION = "spark.sparkVersion";
+
+  /** The service account of the Spark driver, the default of a job's driver 
service account. */
+  public static final String SPARK_SERVICE_ACCOUNT = "spark.serviceAccount";
+
+  /** The default Spark driver service account. */
+  public static final String DEFAULT_SPARK_SERVICE_ACCOUNT = "spark";
+
+  /** How long the pods of a finished Spark job are retained, so that its 
output can be read. */
+  public static final String SPARK_RESOURCE_RETAIN_DURATION_MS = 
"spark.resourceRetainDurationInMs";
+
+  /** The default pod retain duration, 1 day. */
+  public static final long DEFAULT_SPARK_RESOURCE_RETAIN_DURATION_MS = 24 * 60 
* 60 * 1000L;
+
+  /** The time to live of a SparkApplication after it stops. */
+  public static final String SPARK_TTL_AFTER_STOP_MS = 
"spark.ttlAfterStopInMs";
+
+  /** The default SparkApplication time to live after it stops, 7 days. */
+  public static final long DEFAULT_SPARK_TTL_AFTER_STOP_MS = 7 * 24 * 60 * 60 
* 1000L;
+
+  /** How long the operator waits for the driver pod to start. */
+  public static final String SPARK_DRIVER_START_TIMEOUT_MS = 
"spark.driverStartTimeoutInMs";
+
+  /** How long the operator waits for the driver to be ready. */
+  public static final String SPARK_DRIVER_READY_TIMEOUT_MS = 
"spark.driverReadyTimeoutInMs";
+
+  /**
+   * The default of the operator's start timeouts, 1 hour, so that a job waits 
for resources instead
+   * of failing after the operator's default of 5 minutes.
+   */
+  public static final long DEFAULT_SPARK_START_TIMEOUT_MS = 60 * 60 * 1000L;
+
+  /**
+   * The prefix of the default Spark configurations of every Spark job. The 
job's own Spark
+   * configurations take precedence.
+   */
+  public static final String SPARK_CONF_PREFIX = "spark.conf.";
+
+  // The resource name is the prefix, a dash and a job id of up to 19 digits. 
Keep it within 44
+  // characters, so that the operator doesn't hash-truncate the Spark app id 
derived from it.
+  private static final int MAX_NAME_PREFIX_LENGTH = 24;
+
+  // A DNS-1123 label, which the resource name must be.
+  private static final Pattern NAME_PREFIX_PATTERN = 
Pattern.compile("[a-z]([-a-z0-9]*[a-z0-9])?");
+
+  private final String cluster;
+  @Nullable private final String kubeconfig;
+  @Nullable private final String context;
+  @Nullable private final String masterUrl;
+  @Nullable private final String caCertFile;
+  @Nullable private final String tokenFile;
+  private final String namespace;
+  private final int connectionTimeoutMs;
+  private final int requestTimeoutMs;
+  private final int requestRetries;
+  private final String namePrefix;
+  private final long statusCacheTtlMs;
+  private final long noStatusTimeoutMs;
+  private final String sparkImage;
+  private final String sparkVersion;
+  private final String sparkServiceAccount;
+  private final long sparkResourceRetainDurationMs;
+  private final long sparkTtlAfterStopMs;
+  private final long sparkDriverStartTimeoutMs;
+  private final long sparkDriverReadyTimeoutMs;
+  private final Map<String, String> sparkConf;
+
+  /**
+   * Parses and validates the job executor configurations.
+   *
+   * @param configs the configurations without the {@code 
gravitino.jobExecutor.k8s.} prefix
+   * @throws IllegalArgumentException if a configuration is invalid
+   */
+  public K8sJobExecutorConfigs(Map<String, String> configs) {
+    this.cluster = stringValue(configs, CLUSTER, DEFAULT_CLUSTER);
+    Preconditions.checkArgument(
+        !cluster.contains("/"), "%s must not contain '/': %s", CLUSTER, 
cluster);
+
+    this.kubeconfig = stringValue(configs, KUBECONFIG, null);
+    this.context = stringValue(configs, CONTEXT, null);
+    this.masterUrl = stringValue(configs, MASTER_URL, null);
+    this.caCertFile = stringValue(configs, CA_CERT_FILE, null);
+    this.tokenFile = stringValue(configs, TOKEN_FILE, null);
+    Preconditions.checkArgument(
+        masterUrl == null || (kubeconfig == null && context == null),
+        "%s can't be set together with %s or %s",
+        MASTER_URL,
+        KUBECONFIG,
+        CONTEXT);
+    Preconditions.checkArgument(
+        masterUrl != null || (caCertFile == null && tokenFile == null),
+        "%s and %s can only be set when %s is set",
+        CA_CERT_FILE,
+        TOKEN_FILE,
+        MASTER_URL);
+    // Without a scheme the client would pick one by probing the server, and 
fall back to plain
+    // HTTP if the probe fails, for example because the server isn't reachable 
at startup.
+    boolean https = StringUtils.startsWithIgnoreCase(masterUrl, "https://";);
+    Preconditions.checkArgument(
+        masterUrl == null || https || 
StringUtils.startsWithIgnoreCase(masterUrl, "http://";),
+        "%s must start with https:// or http://: %s",
+        MASTER_URL,
+        masterUrl);
+    Preconditions.checkArgument(
+        masterUrl == null || https || tokenFile == null,
+        "%s must start with https:// when %s is set, otherwise the token would 
be sent in clear"
+            + " text: %s",
+        MASTER_URL,
+        TOKEN_FILE,
+        masterUrl);
+    Preconditions.checkArgument(
+        masterUrl == null || https || caCertFile == null,
+        "%s has no effect on an http:// %s, which isn't verified nor 
encrypted: %s",
+        CA_CERT_FILE,
+        MASTER_URL,
+        masterUrl);
+
+    this.namespace = stringValue(configs, NAMESPACE, null);
+    Preconditions.checkArgument(namespace != null, "%s must be set", 
NAMESPACE);
+    Preconditions.checkArgument(
+        K8sJobResourceUtils.isValidNamespace(namespace),
+        "%s isn't a valid namespace name: %s",
+        NAMESPACE,
+        namespace);
+    this.connectionTimeoutMs =
+        intValue(
+            CONNECTION_TIMEOUT_MS,
+            positiveLongValue(configs, CONNECTION_TIMEOUT_MS, 
DEFAULT_CONNECTION_TIMEOUT_MS));
+    this.requestTimeoutMs =
+        intValue(
+            REQUEST_TIMEOUT_MS,
+            positiveLongValue(configs, REQUEST_TIMEOUT_MS, 
DEFAULT_REQUEST_TIMEOUT_MS));
+    long retries = longValue(configs, REQUEST_RETRIES, 
DEFAULT_REQUEST_RETRIES);
+    Preconditions.checkArgument(
+        retries >= 0, "%s must not be negative: %s", REQUEST_RETRIES, retries);
+    this.requestRetries = intValue(REQUEST_RETRIES, retries);
+
+    this.namePrefix = stringValue(configs, NAME_PREFIX, DEFAULT_NAME_PREFIX);
+    Preconditions.checkArgument(
+        namePrefix.length() <= MAX_NAME_PREFIX_LENGTH
+            && NAME_PREFIX_PATTERN.matcher(namePrefix).matches(),
+        "%s must be at most %s characters of lowercase letters, digits and 
'-', starting with a"
+            + " letter and ending with a letter or digit: %s",
+        NAME_PREFIX,
+        MAX_NAME_PREFIX_LENGTH,
+        namePrefix);
+
+    this.statusCacheTtlMs = longValue(configs, STATUS_CACHE_TTL_MS, 
DEFAULT_STATUS_CACHE_TTL_MS);
+    this.noStatusTimeoutMs =
+        positiveLongValue(configs, NO_STATUS_TIMEOUT_MS, 
DEFAULT_NO_STATUS_TIMEOUT_MS);
+
+    this.sparkImage = stringValue(configs, SPARK_IMAGE, null);
+    Preconditions.checkArgument(sparkImage != null, "%s must be set", 
SPARK_IMAGE);
+    this.sparkVersion = stringValue(configs, SPARK_VERSION, null);
+    Preconditions.checkArgument(sparkVersion != null, "%s must be set", 
SPARK_VERSION);
+    this.sparkServiceAccount =
+        stringValue(configs, SPARK_SERVICE_ACCOUNT, 
DEFAULT_SPARK_SERVICE_ACCOUNT);
+    this.sparkResourceRetainDurationMs =
+        positiveLongValue(
+            configs, SPARK_RESOURCE_RETAIN_DURATION_MS, 
DEFAULT_SPARK_RESOURCE_RETAIN_DURATION_MS);
+    this.sparkTtlAfterStopMs =
+        positiveLongValue(configs, SPARK_TTL_AFTER_STOP_MS, 
DEFAULT_SPARK_TTL_AFTER_STOP_MS);
+    this.sparkDriverStartTimeoutMs =
+        positiveLongValue(configs, SPARK_DRIVER_START_TIMEOUT_MS, 
DEFAULT_SPARK_START_TIMEOUT_MS);
+    this.sparkDriverReadyTimeoutMs =
+        positiveLongValue(configs, SPARK_DRIVER_READY_TIMEOUT_MS, 
DEFAULT_SPARK_START_TIMEOUT_MS);
+
+    Map<String, String> conf = new TreeMap<>();
+    configs.forEach(
+        (key, value) -> {
+          if (key.startsWith(SPARK_CONF_PREFIX) && key.length() > 
SPARK_CONF_PREFIX.length()) {
+            conf.put(key.substring(SPARK_CONF_PREFIX.length()), value);
+          }
+        });
+    this.sparkConf = ImmutableMap.copyOf(conf);
+  }
+
+  /**
+   * Returns the name of the Kubernetes cluster the jobs run in.
+   *
+   * @return the cluster name
+   */
+  public String cluster() {
+    return cluster;
+  }
+
+  /**
+   * Returns the kubeconfig file of the job cluster.
+   *
+   * @return the kubeconfig file, or null if not set
+   */
+  @Nullable
+  public String kubeconfig() {
+    return kubeconfig;
+  }
+
+  /**
+   * Returns the kubeconfig context to use.
+   *
+   * @return the kubeconfig context, or null for the current context
+   */
+  @Nullable
+  public String context() {
+    return context;
+  }
+
+  /**
+   * Returns the API server URL of the job cluster.
+   *
+   * @return the API server URL, or null if not set
+   */
+  @Nullable
+  public String masterUrl() {
+    return masterUrl;
+  }
+
+  /**
+   * Returns the CA certificate file of the API server.
+   *
+   * @return the CA certificate file, or null if not set
+   */
+  @Nullable
+  public String caCertFile() {
+    return caCertFile;
+  }
+
+  /**
+   * Returns the bearer token file to access the API server.
+   *
+   * @return the token file, or null if not set
+   */
+  @Nullable
+  public String tokenFile() {
+    return tokenFile;
+  }
+
+  /**
+   * Returns the namespace the jobs run in, unless a job sets its own.
+   *
+   * @return the namespace of the jobs
+   */
+  public String namespace() {
+    return namespace;
+  }
+
+  /**
+   * Returns how long to wait to connect to the API server.
+   *
+   * @return the connection timeout in milliseconds
+   */
+  public int connectionTimeoutMs() {
+    return connectionTimeoutMs;
+  }
+
+  /**
+   * Returns how long to wait for the response of a request to the API server.
+   *
+   * @return the request timeout in milliseconds
+   */
+  public int requestTimeoutMs() {
+    return requestTimeoutMs;
+  }
+
+  /**
+   * Returns how many times a failed request to the API server is tried again.
+   *
+   * @return the number of request retries
+   */
+  public int requestRetries() {
+    return requestRetries;
+  }
+
+  /**
+   * Returns the prefix of the resource names.
+   *
+   * @return the name prefix
+   */
+  public String namePrefix() {
+    return namePrefix;
+  }
+
+  /**
+   * Returns how long the resources listed in a namespace are cached.
+   *
+   * @return the status cache TTL in milliseconds, caching is disabled if not 
positive
+   */
+  public long statusCacheTtlMs() {
+    return statusCacheTtlMs;
+  }
+
+  /**
+   * Returns the time after which a job whose resource has no status is 
considered failed.
+   *
+   * @return the no status timeout in milliseconds
+   */
+  public long noStatusTimeoutMs() {
+    return noStatusTimeoutMs;
+  }
+
+  /**
+   * Returns the default Spark image.
+   *
+   * @return the Spark image
+   */
+  public String sparkImage() {
+    return sparkImage;
+  }
+
+  /**
+   * Returns the Spark version of the image.
+   *
+   * @return the Spark version
+   */
+  public String sparkVersion() {
+    return sparkVersion;
+  }
+
+  /**
+   * Returns the default service account of the Spark driver.
+   *
+   * @return the driver service account
+   */
+  public String sparkServiceAccount() {
+    return sparkServiceAccount;
+  }
+
+  /**
+   * Returns how long the pods of a finished Spark job are retained.
+   *
+   * @return the pod retain duration in milliseconds
+   */
+  public long sparkResourceRetainDurationMs() {
+    return sparkResourceRetainDurationMs;
+  }
+
+  /**
+   * Returns the time to live of a SparkApplication after it stops.
+   *
+   * @return the time to live in milliseconds
+   */
+  public long sparkTtlAfterStopMs() {
+    return sparkTtlAfterStopMs;
+  }
+
+  /**
+   * Returns how long the operator waits for the driver pod to start.
+   *
+   * @return the timeout in milliseconds
+   */
+  public long sparkDriverStartTimeoutMs() {
+    return sparkDriverStartTimeoutMs;
+  }
+
+  /**
+   * Returns how long the operator waits for the driver to be ready.
+   *
+   * @return the timeout in milliseconds
+   */
+  public long sparkDriverReadyTimeoutMs() {
+    return sparkDriverReadyTimeoutMs;
+  }
+
+  /**
+   * Returns the default Spark configurations of every Spark job.
+   *
+   * @return the default Spark configurations
+   */
+  public Map<String, String> sparkConf() {
+    return sparkConf;
+  }
+
+  @Nullable
+  private static String stringValue(
+      Map<String, String> configs, String key, @Nullable String defaultValue) {
+    String value = configs.get(key);
+    return StringUtils.isNotBlank(value) ? value.trim() : defaultValue;
+  }
+
+  private static long longValue(Map<String, String> configs, String key, long 
defaultValue) {
+    String value = stringValue(configs, key, null);
+    if (value == null) {
+      return defaultValue;
+    }
+    try {
+      return Long.parseLong(value);
+    } catch (NumberFormatException e) {
+      throw new IllegalArgumentException(String.format("%s must be a number: 
%s", key, value), e);
+    }
+  }
+
+  private static int intValue(String key, long value) {
+    Preconditions.checkArgument(value <= Integer.MAX_VALUE, "%s is too large: 
%s", key, value);
+    return (int) value;
+  }
+
+  private static long positiveLongValue(
+      Map<String, String> configs, String key, long defaultValue) {
+    long value = longValue(configs, key, defaultValue);
+    Preconditions.checkArgument(value > 0, "%s must be positive: %s", key, 
value);
+    return value;
+  }
+}
diff --git 
a/plugins/k8s-job-executor/src/main/java/org/apache/gravitino/job/k8s/K8sJobResourceUtils.java
 
b/plugins/k8s-job-executor/src/main/java/org/apache/gravitino/job/k8s/K8sJobResourceUtils.java
new file mode 100644
index 0000000000..a0ffb490e8
--- /dev/null
+++ 
b/plugins/k8s-job-executor/src/main/java/org/apache/gravitino/job/k8s/K8sJobResourceUtils.java
@@ -0,0 +1,106 @@
+/*
+ * 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.collect.ImmutableMap;
+import io.fabric8.kubernetes.api.model.HasMetadata;
+import java.util.Map;
+import java.util.regex.Pattern;
+
+/** The naming and labeling of the Kubernetes resources created for the jobs. 
*/
+public final class K8sJobResourceUtils {
+
+  /** The label that marks the resources managed by Gravitino. */
+  public static final String LABEL_MANAGED_BY = "app.kubernetes.io/managed-by";
+
+  /** The {@link #LABEL_MANAGED_BY} value of the resources managed by 
Gravitino. */
+  public static final String MANAGED_BY_GRAVITINO = "gravitino";
+
+  /** The label whose value is the Gravitino job id of the resource. */
+  public static final String LABEL_JOB_ID = "gravitino.apache.org/job-id";
+
+  /** The annotation whose value is the metalake of the job. */
+  public static final String ANNOTATION_METALAKE = 
"gravitino.apache.org/metalake";
+
+  /** The annotation set when Gravitino requests to cancel the job. */
+  public static final String ANNOTATION_CANCEL_REQUESTED = 
"gravitino.apache.org/cancel-requested";
+
+  // A DNS-1123 label, which a namespace name must be.
+  private static final Pattern NAMESPACE_PATTERN =
+      Pattern.compile("[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?");
+
+  private K8sJobResourceUtils() {}
+
+  /**
+   * Returns whether the given name is a valid name of a Kubernetes namespace.
+   *
+   * @param namespace the namespace name
+   * @return true if the name is a DNS-1123 label
+   */
+  public static boolean isValidNamespace(String namespace) {
+    return namespace != null && NAMESPACE_PATTERN.matcher(namespace).matches();
+  }
+
+  /**
+   * Returns the name of the Kubernetes resource of a job.
+   *
+   * @param configs the job executor configurations
+   * @param jobId the Gravitino job id
+   * @return the resource name
+   */
+  public static String resourceName(K8sJobExecutorConfigs configs, long jobId) 
{
+    return configs.namePrefix() + "-" + jobId;
+  }
+
+  /**
+   * Returns the labels of the Kubernetes resource of a job.
+   *
+   * @param jobId the Gravitino job id
+   * @return the labels
+   */
+  public static Map<String, String> jobLabels(long jobId) {
+    return ImmutableMap.of(
+        LABEL_MANAGED_BY, MANAGED_BY_GRAVITINO, LABEL_JOB_ID, 
String.valueOf(jobId));
+  }
+
+  /**
+   * Returns whether the Kubernetes resource was created for the given job.
+   *
+   * @param resource the resource
+   * @param jobId the Gravitino job id
+   * @return true if the resource carries the job id label of the job
+   */
+  public static boolean isOfJob(HasMetadata resource, long jobId) {
+    Map<String, String> labels =
+        resource.getMetadata() == null ? null : 
resource.getMetadata().getLabels();
+    return labels != null && 
String.valueOf(jobId).equals(labels.get(LABEL_JOB_ID));
+  }
+
+  /**
+   * Returns whether Gravitino requested to cancel the job of the Kubernetes 
resource.
+   *
+   * @param resource the resource
+   * @return true if the cancel annotation is set
+   */
+  public static boolean isCancelRequested(HasMetadata resource) {
+    Map<String, String> annotations =
+        resource.getMetadata() == null ? null : 
resource.getMetadata().getAnnotations();
+    return annotations != null && 
"true".equals(annotations.get(ANNOTATION_CANCEL_REQUESTED));
+  }
+}
diff --git 
a/plugins/k8s-job-executor/src/main/java/org/apache/gravitino/job/k8s/K8sKubeconfigUtils.java
 
b/plugins/k8s-job-executor/src/main/java/org/apache/gravitino/job/k8s/K8sKubeconfigUtils.java
new file mode 100644
index 0000000000..1b7888c11e
--- /dev/null
+++ 
b/plugins/k8s-job-executor/src/main/java/org/apache/gravitino/job/k8s/K8sKubeconfigUtils.java
@@ -0,0 +1,115 @@
+/*
+ * 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.api.model.Cluster;
+import io.fabric8.kubernetes.api.model.Config;
+import io.fabric8.kubernetes.api.model.Context;
+import io.fabric8.kubernetes.api.model.NamedCluster;
+import io.fabric8.kubernetes.api.model.NamedContext;
+import io.fabric8.kubernetes.client.utils.Serialization;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import javax.annotation.Nullable;
+import org.apache.commons.lang3.StringUtils;
+
+/**
+ * Reads a kubeconfig file into the model of the Kubernetes client, to check 
what the client will
+ * make of it.
+ */
+final class K8sKubeconfigUtils {
+
+  private K8sKubeconfigUtils() {}
+
+  /**
+   * Returns the API server of the cluster that the kubeconfig selects, and 
fails if it selects
+   * none. Without one the client keeps its default API server, the one of the 
cluster Gravitino
+   * runs in, and would silently run the jobs there.
+   *
+   * @param kubeconfig the kubeconfig file, set by {@link 
K8sJobExecutorConfigs#KUBECONFIG}
+   * @param contextName the context to use, or null for the current context of 
the kubeconfig
+   * @return the server of the cluster of the context
+   * @throws IllegalArgumentException if the kubeconfig can't be parsed, or 
its context doesn't
+   *     select a cluster with a server
+   */
+  static String getSelectedServer(File kubeconfig, @Nullable String 
contextName) {
+    Config content;
+    try (InputStream in = Files.newInputStream(kubeconfig.toPath())) {
+      content = Serialization.unmarshal(in, Config.class);
+    } catch (IOException | RuntimeException e) {
+      throw new IllegalArgumentException(
+          String.format(
+              "The kubeconfig %s set by %s can't be parsed",
+              kubeconfig, K8sJobExecutorConfigs.KUBECONFIG),
+          e);
+    }
+    // An empty file has no content at all.
+    Preconditions.checkArgument(
+        content != null,
+        "The kubeconfig %s set by %s is empty",
+        kubeconfig,
+        K8sJobExecutorConfigs.KUBECONFIG);
+
+    String name = contextName != null ? contextName : 
content.getCurrentContext();
+    Preconditions.checkArgument(
+        StringUtils.isNotBlank(name),
+        "The kubeconfig %s set by %s has no current context, set its 
current-context or %s",
+        kubeconfig,
+        K8sJobExecutorConfigs.KUBECONFIG,
+        K8sJobExecutorConfigs.CONTEXT);
+
+    // Like the client, the last entry with a name wins, and an entry without 
content is skipped.
+    Context context = null;
+    if (content.getContexts() != null) {
+      for (NamedContext entry : content.getContexts()) {
+        if (entry != null && name.equals(entry.getName()) && 
entry.getContext() != null) {
+          context = entry.getContext();
+        }
+      }
+    }
+    Preconditions.checkArgument(
+        context != null,
+        "The context %s doesn't exist in the kubeconfig %s set by %s",
+        name,
+        kubeconfig,
+        K8sJobExecutorConfigs.KUBECONFIG);
+
+    Cluster cluster = null;
+    if (content.getClusters() != null && context.getCluster() != null) {
+      for (NamedCluster entry : content.getClusters()) {
+        if (entry != null
+            && context.getCluster().equals(entry.getName())
+            && entry.getCluster() != null) {
+          cluster = entry.getCluster();
+        }
+      }
+    }
+    Preconditions.checkArgument(
+        cluster != null && StringUtils.isNotBlank(cluster.getServer()),
+        "The cluster %s of the context %s has no server in the kubeconfig %s 
set by %s",
+        context.getCluster(),
+        name,
+        kubeconfig,
+        K8sJobExecutorConfigs.KUBECONFIG);
+    return cluster.getServer();
+  }
+}
diff --git 
a/plugins/k8s-job-executor/src/test/java/org/apache/gravitino/job/k8s/TestK8sClientUtils.java
 
b/plugins/k8s-job-executor/src/test/java/org/apache/gravitino/job/k8s/TestK8sClientUtils.java
new file mode 100644
index 0000000000..cfa362f32c
--- /dev/null
+++ 
b/plugins/k8s-job-executor/src/test/java/org/apache/gravitino/job/k8s/TestK8sClientUtils.java
@@ -0,0 +1,451 @@
+/*
+ * 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.collect.ImmutableList;
+import io.fabric8.kubernetes.client.Config;
+import io.fabric8.kubernetes.client.KubernetesClient;
+import io.fabric8.kubernetes.client.KubernetesClientException;
+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.attribute.FileTime;
+import java.time.Duration;
+import java.util.HashMap;
+import java.util.Map;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+public class TestK8sClientUtils {
+
+  private static final String KUBECONFIG =
+      "apiVersion: v1\n"
+          + "kind: Config\n"
+          + "current-context: dev\n"
+          + "clusters:\n"
+          + "- name: dev\n"
+          + "  cluster: {server: 'https://dev:6443'}\n"
+          + "- name: prod\n"
+          + "  cluster: {server: 'https://prod:6443'}\n"
+          + "users:\n"
+          + "- name: admin\n"
+          + "  user: {token: admin-token}\n"
+          + "contexts:\n"
+          + "- name: dev\n"
+          + "  context: {cluster: dev, user: admin}\n"
+          + "- name: prod\n"
+          + "  context: {cluster: prod, user: admin}\n";
+
+  @TempDir Path dir;
+
+  @Test
+  public void testMasterUrl() throws IOException {
+    Path caCert = Files.write(dir.resolve("ca.crt"), 
"cert".getBytes(StandardCharsets.UTF_8));
+    Path token = Files.write(dir.resolve("token"), 
"token-1\n".getBytes(StandardCharsets.UTF_8));
+    Map<String, String> map = new 
HashMap<>(TestK8sJobExecutorConfigs.requiredConfigs());
+    map.put(K8sJobExecutorConfigs.MASTER_URL, "https://jobs:6443";);
+    map.put(K8sJobExecutorConfigs.CA_CERT_FILE, caCert.toString());
+    map.put(K8sJobExecutorConfigs.TOKEN_FILE, token.toString());
+
+    Config config = K8sClientUtils.createClientConfig(new 
K8sJobExecutorConfigs(map));
+
+    Assertions.assertEquals("https://jobs:6443/";, config.getMasterUrl());
+    Assertions.assertEquals(caCert.toString(), config.getCaCertFile());
+    Assertions.assertEquals("token-1", 
config.getOauthTokenProvider().getToken());
+  }
+
+  @Test
+  public void testMissingFiles() throws IOException {
+    Map<String, String> map = new 
HashMap<>(TestK8sJobExecutorConfigs.requiredConfigs());
+    map.put(K8sJobExecutorConfigs.MASTER_URL, "https://jobs:6443";);
+    map.put(K8sJobExecutorConfigs.TOKEN_FILE, 
dir.resolve("missing").toString());
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () -> K8sClientUtils.createClientConfig(new 
K8sJobExecutorConfigs(map)));
+
+    // A token file without a token fails the startup too, rather than the 
first request.
+    map.put(
+        K8sJobExecutorConfigs.TOKEN_FILE,
+        Files.write(dir.resolve("empty-token"), new byte[0]).toString());
+    IllegalArgumentException e =
+        Assertions.assertThrows(
+            IllegalArgumentException.class,
+            () -> K8sClientUtils.createClientConfig(new 
K8sJobExecutorConfigs(map)));
+    Assertions.assertTrue(e.getMessage().contains("No token can be read"), 
e.getMessage());
+
+    Map<String, String> kubeconfig = new 
HashMap<>(TestK8sJobExecutorConfigs.requiredConfigs());
+    kubeconfig.put(K8sJobExecutorConfigs.KUBECONFIG, 
dir.resolve("missing").toString());
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () -> K8sClientUtils.createClientConfig(new 
K8sJobExecutorConfigs(kubeconfig)));
+  }
+
+  @Test
+  public void testKubeconfig() throws IOException {
+    Path kubeconfig =
+        Files.write(dir.resolve("config"), 
KUBECONFIG.getBytes(StandardCharsets.UTF_8));
+    Map<String, String> map = new 
HashMap<>(TestK8sJobExecutorConfigs.requiredConfigs());
+    map.put(K8sJobExecutorConfigs.KUBECONFIG, kubeconfig.toString());
+
+    Config current = K8sClientUtils.createClientConfig(new 
K8sJobExecutorConfigs(map));
+    Assertions.assertEquals("https://dev:6443/";, current.getMasterUrl());
+
+    map.put(K8sJobExecutorConfigs.CONTEXT, "prod");
+    Config prod = K8sClientUtils.createClientConfig(new 
K8sJobExecutorConfigs(map));
+    Assertions.assertEquals("https://prod:6443/";, prod.getMasterUrl());
+    Assertions.assertEquals("admin-token", prod.getAutoOAuthToken());
+
+    // A context that doesn't exist must not fall back to the current one.
+    map.put(K8sJobExecutorConfigs.CONTEXT, "staging");
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () -> K8sClientUtils.createClientConfig(new 
K8sJobExecutorConfigs(map)));
+  }
+
+  @Test
+  public void testKubeconfigWithoutUsableCluster() throws IOException {
+    // Without a context that selects a cluster with a server, the client 
would silently fall
+    // back to the API server of the cluster Gravitino itself runs in.
+    String devContext = "context: {cluster: dev, user: admin}";
+    String devCluster = "- name: dev\n  cluster: {server: 
'https://dev:6443'}\n";
+    String[] unusable = {
+      // No current context.
+      KUBECONFIG.replace("current-context: dev\n", ""),
+      // The current context doesn't exist.
+      KUBECONFIG.replace("current-context: dev\n", "current-context: 
staging\n"),
+      // The context has no content, or no cluster.
+      KUBECONFIG.replace("  " + devContext + "\n", ""),
+      KUBECONFIG.replace(devContext, "context: {user: admin}"),
+      // The cluster of the context isn't defined.
+      KUBECONFIG.replace(devContext, "context: {cluster: nope}"),
+      // The cluster is defined without content, with null or empty content, 
or a blank server.
+      KUBECONFIG.replace(devCluster, "- name: dev\n"),
+      KUBECONFIG.replace(devCluster, "- name: dev\n  cluster: null\n"),
+      KUBECONFIG.replace(devCluster, "- name: dev\n  cluster: {}\n"),
+      KUBECONFIG.replace(devCluster, "- name: dev\n  cluster: {server: ' 
'}\n"),
+      // Not a kubeconfig at all.
+      "- just\n- a list\n",
+    };
+    for (String content : unusable) {
+      Assertions.assertNotEquals(KUBECONFIG, content);
+      Path kubeconfig =
+          Files.write(dir.resolve("config"), 
content.getBytes(StandardCharsets.UTF_8));
+      Map<String, String> map = new 
HashMap<>(TestK8sJobExecutorConfigs.requiredConfigs());
+      map.put(K8sJobExecutorConfigs.KUBECONFIG, kubeconfig.toString());
+      Assertions.assertThrows(
+          IllegalArgumentException.class,
+          () -> K8sClientUtils.createClientConfig(new 
K8sJobExecutorConfigs(map)),
+          content);
+    }
+
+    // Naming a usable context works, whatever the current context is.
+    Path kubeconfig =
+        Files.write(
+            dir.resolve("config"),
+            KUBECONFIG.replace(devCluster, "- name: 
dev\n").getBytes(StandardCharsets.UTF_8));
+    Map<String, String> map = new 
HashMap<>(TestK8sJobExecutorConfigs.requiredConfigs());
+    map.put(K8sJobExecutorConfigs.KUBECONFIG, kubeconfig.toString());
+    map.put(K8sJobExecutorConfigs.CONTEXT, "prod");
+    Assertions.assertEquals(
+        "https://prod:6443/";,
+        K8sClientUtils.createClientConfig(new 
K8sJobExecutorConfigs(map)).getMasterUrl());
+  }
+
+  @Test
+  public void testKubeconfigSelectingDefaultServer() throws IOException {
+    // A kubeconfig may well select the default API server of the client.
+    Path kubeconfig =
+        Files.write(
+            dir.resolve("config"),
+            KUBECONFIG
+                .replace("https://dev:6443";, "https://kubernetes.default.svc";)
+                .getBytes(StandardCharsets.UTF_8));
+    Map<String, String> map = new 
HashMap<>(TestK8sJobExecutorConfigs.requiredConfigs());
+    map.put(K8sJobExecutorConfigs.KUBECONFIG, kubeconfig.toString());
+    Assertions.assertEquals(
+        "https://kubernetes.default.svc/";,
+        K8sClientUtils.createClientConfig(new 
K8sJobExecutorConfigs(map)).getMasterUrl());
+  }
+
+  @Test
+  public void testKubeconfigServerWithoutScheme() throws IOException {
+    // The client picks the scheme of such a server by probing it, whatever it 
picks is accepted.
+    // Nothing listens on the port, so the probe fails right away.
+    Path kubeconfig =
+        Files.write(
+            dir.resolve("config"),
+            KUBECONFIG.replace("https://dev:6443";, 
"localhost:1").getBytes(StandardCharsets.UTF_8));
+    Map<String, String> map = new 
HashMap<>(TestK8sJobExecutorConfigs.requiredConfigs());
+    map.put(K8sJobExecutorConfigs.KUBECONFIG, kubeconfig.toString());
+    String masterUrl =
+        K8sClientUtils.createClientConfig(new 
K8sJobExecutorConfigs(map)).getMasterUrl();
+    Assertions.assertTrue(masterUrl.endsWith("://localhost:1/"), masterUrl);
+
+    Files.write(
+        kubeconfig,
+        KUBECONFIG
+            .replace("https://dev:6443";, "HTTPS://dev:6443/")
+            .getBytes(StandardCharsets.UTF_8));
+    // The scheme is compared ignoring its case, and the client keeps the 
server as written.
+    Assertions.assertEquals(
+        "HTTPS://dev:6443/",
+        K8sClientUtils.createClientConfig(new 
K8sJobExecutorConfigs(map)).getMasterUrl());
+  }
+
+  @Test
+  public void testKubeconfigWithDuplicateClusters() throws IOException {
+    // Like the client, the last cluster with a name wins.
+    Path kubeconfig =
+        Files.write(
+            dir.resolve("config"),
+            KUBECONFIG
+                .replace(
+                    "- name: prod\n",
+                    "- name: dev\n  cluster: {server: 'https://dev2:6443'}\n- 
name: prod\n")
+                .getBytes(StandardCharsets.UTF_8));
+    Map<String, String> map = new 
HashMap<>(TestK8sJobExecutorConfigs.requiredConfigs());
+    map.put(K8sJobExecutorConfigs.KUBECONFIG, kubeconfig.toString());
+    Assertions.assertEquals(
+        "https://dev2:6443/";,
+        K8sClientUtils.createClientConfig(new 
K8sJobExecutorConfigs(map)).getMasterUrl());
+  }
+
+  @Test
+  public void testEmptyKubeconfig() throws IOException {
+    for (String content : new String[] {"", "# nothing\n", "---\n"}) {
+      Path kubeconfig =
+          Files.write(dir.resolve("config"), 
content.getBytes(StandardCharsets.UTF_8));
+      Map<String, String> map = new 
HashMap<>(TestK8sJobExecutorConfigs.requiredConfigs());
+      map.put(K8sJobExecutorConfigs.KUBECONFIG, kubeconfig.toString());
+      Assertions.assertThrows(
+          IllegalArgumentException.class,
+          () -> K8sClientUtils.createClientConfig(new 
K8sJobExecutorConfigs(map)),
+          content);
+    }
+  }
+
+  @Test
+  public void testDefaultLookupWithoutUsableCluster() throws IOException {
+    // The client prefers this system property over the KUBECONFIG environment 
variable.
+    String property = "kubeconfig";
+    String before = System.getProperty(property);
+    Path kubeconfig = dir.resolve("config");
+    System.setProperty(property, kubeconfig.toString());
+    try {
+      Files.write(kubeconfig, KUBECONFIG.getBytes(StandardCharsets.UTF_8));
+      Map<String, String> map = new 
HashMap<>(TestK8sJobExecutorConfigs.requiredConfigs());
+      Assertions.assertEquals(
+          "https://dev:6443/";,
+          K8sClientUtils.createClientConfig(new 
K8sJobExecutorConfigs(map)).getMasterUrl());
+
+      // The cluster of the context isn't defined, so the client would stay on 
the API server of
+      // the cluster Gravitino itself runs in.
+      Files.write(
+          kubeconfig,
+          KUBECONFIG
+              .replace("context: {cluster: dev, user: admin}", "context: 
{cluster: nope}")
+              .getBytes(StandardCharsets.UTF_8));
+      IllegalArgumentException e =
+          Assertions.assertThrows(
+              IllegalArgumentException.class,
+              () -> K8sClientUtils.createClientConfig(new 
K8sJobExecutorConfigs(map)));
+      Assertions.assertTrue(e.getMessage().contains("selects no cluster"), 
e.getMessage());
+
+      map.put(K8sJobExecutorConfigs.CONTEXT, "dev");
+      Assertions.assertThrows(
+          IllegalArgumentException.class,
+          () -> K8sClientUtils.createClientConfig(new 
K8sJobExecutorConfigs(map)));
+
+      map.put(K8sJobExecutorConfigs.CONTEXT, "prod");
+      Assertions.assertEquals(
+          "https://prod:6443/";,
+          K8sClientUtils.createClientConfig(new 
K8sJobExecutorConfigs(map)).getMasterUrl());
+
+      // A context that doesn't exist is reported as such, even if the current 
context that the
+      // client falls back to is broken too.
+      map.put(K8sJobExecutorConfigs.CONTEXT, "staging");
+      e =
+          Assertions.assertThrows(
+              IllegalArgumentException.class,
+              () -> K8sClientUtils.createClientConfig(new 
K8sJobExecutorConfigs(map)));
+      Assertions.assertTrue(e.getMessage().contains("The context staging"), 
e.getMessage());
+
+      // No context is selected: no current context, or one that doesn't exist.
+      map.remove(K8sJobExecutorConfigs.CONTEXT);
+      for (String content :
+          new String[] {
+            KUBECONFIG.replace("current-context: dev\n", ""),
+            KUBECONFIG.replace("current-context: dev\n", "current-context: 
staging\n")
+          }) {
+        Files.write(kubeconfig, content.getBytes(StandardCharsets.UTF_8));
+        e =
+            Assertions.assertThrows(
+                IllegalArgumentException.class,
+                () -> K8sClientUtils.createClientConfig(new 
K8sJobExecutorConfigs(map)),
+                content);
+        Assertions.assertTrue(e.getMessage().contains("has no current 
context"), e.getMessage());
+      }
+
+      // Without any kubeconfig, and outside a pod, no cluster is configured 
at all.
+      System.setProperty(property, dir.resolve("missing").toString());
+      e =
+          Assertions.assertThrows(
+              IllegalArgumentException.class,
+              () -> K8sClientUtils.createClientConfig(new 
K8sJobExecutorConfigs(map)));
+      Assertions.assertTrue(e.getMessage().contains("No Kubernetes cluster"), 
e.getMessage());
+
+      // In a pod, the kubelet sets the API server through these, which the 
client reads from
+      // the system properties first.
+      System.setProperty("KUBERNETES_SERVICE_HOST", "10.0.0.1");
+      System.setProperty("KUBERNETES_SERVICE_PORT", "443");
+      try {
+        Assertions.assertEquals(
+            "https://10.0.0.1:443/";,
+            K8sClientUtils.createClientConfig(new 
K8sJobExecutorConfigs(map)).getMasterUrl());
+      } finally {
+        System.clearProperty("KUBERNETES_SERVICE_HOST");
+        System.clearProperty("KUBERNETES_SERVICE_PORT");
+      }
+    } finally {
+      if (before == null) {
+        System.clearProperty(property);
+      } else {
+        System.setProperty(property, before);
+      }
+    }
+  }
+
+  @Test
+  public void testRequestLimits() throws IOException {
+    // The limits are set in every connection mode.
+    Path kubeconfig =
+        Files.write(dir.resolve("config"), 
KUBECONFIG.getBytes(StandardCharsets.UTF_8));
+    Map<String, String> withKubeconfig = new 
HashMap<>(TestK8sJobExecutorConfigs.requiredConfigs());
+    withKubeconfig.put(K8sJobExecutorConfigs.KUBECONFIG, 
kubeconfig.toString());
+    Map<String, String> withMasterUrl = new 
HashMap<>(TestK8sJobExecutorConfigs.requiredConfigs());
+    withMasterUrl.put(K8sJobExecutorConfigs.MASTER_URL, "https://jobs:6443";);
+    for (Map<String, String> map : ImmutableList.of(withKubeconfig, 
withMasterUrl)) {
+      Config defaults = K8sClientUtils.createClientConfig(new 
K8sJobExecutorConfigs(map));
+      Assertions.assertEquals(5_000, defaults.getConnectionTimeout());
+      Assertions.assertEquals(10_000, defaults.getRequestTimeout());
+      Assertions.assertEquals(2, defaults.getRequestRetryBackoffLimit());
+
+      map.put(K8sJobExecutorConfigs.CONNECTION_TIMEOUT_MS, "1000");
+      map.put(K8sJobExecutorConfigs.REQUEST_TIMEOUT_MS, "2000");
+      map.put(K8sJobExecutorConfigs.REQUEST_RETRIES, "0");
+      Config custom = K8sClientUtils.createClientConfig(new 
K8sJobExecutorConfigs(map));
+      Assertions.assertEquals(1_000, custom.getConnectionTimeout());
+      Assertions.assertEquals(2_000, custom.getRequestTimeout());
+      Assertions.assertEquals(0, custom.getRequestRetryBackoffLimit());
+    }
+  }
+
+  @Test
+  public void testRequestFailsFastWithoutApiServer() {
+    // Nothing listens on the port. With the client's own defaults this takes 
about 20 seconds.
+    Map<String, String> map = new 
HashMap<>(TestK8sJobExecutorConfigs.requiredConfigs());
+    map.put(K8sJobExecutorConfigs.MASTER_URL, "https://127.0.0.1:1";);
+    try (KubernetesClient client = K8sClientUtils.createClient(new 
K8sJobExecutorConfigs(map))) {
+      Assertions.assertTimeout(
+          Duration.ofSeconds(10),
+          () ->
+              Assertions.assertThrows(
+                  KubernetesClientException.class,
+                  () -> client.pods().inNamespace("default").list()));
+    }
+  }
+
+  @Test
+  public void testNamespace() throws IOException {
+    // The configured namespace wins over the one of the kubeconfig context.
+    Path kubeconfig =
+        Files.write(
+            dir.resolve("config"),
+            KUBECONFIG
+                .replace(
+                    "{cluster: dev, user: admin}", "{cluster: dev, user: 
admin, namespace: ctx}")
+                .getBytes(StandardCharsets.UTF_8));
+    Map<String, String> map = new 
HashMap<>(TestK8sJobExecutorConfigs.requiredConfigs());
+    map.put(K8sJobExecutorConfigs.KUBECONFIG, kubeconfig.toString());
+    map.put(K8sJobExecutorConfigs.NAMESPACE, "jobs");
+    Assertions.assertEquals(
+        "jobs", K8sClientUtils.createClientConfig(new 
K8sJobExecutorConfigs(map)).getNamespace());
+
+    Map<String, String> masterUrl = new 
HashMap<>(TestK8sJobExecutorConfigs.requiredConfigs());
+    masterUrl.put(K8sJobExecutorConfigs.MASTER_URL, "https://jobs:6443";);
+    masterUrl.put(K8sJobExecutorConfigs.NAMESPACE, "jobs");
+    Assertions.assertEquals(
+        "jobs",
+        K8sClientUtils.createClientConfig(new 
K8sJobExecutorConfigs(masterUrl)).getNamespace());
+  }
+
+  @Test
+  public void testTokenRotation() throws IOException {
+    Path token = Files.write(dir.resolve("token"), 
"token-1".getBytes(StandardCharsets.UTF_8));
+    K8sClientUtils.FileTokenProvider provider = new 
K8sClientUtils.FileTokenProvider(token);
+    Assertions.assertEquals("token-1", provider.getToken());
+
+    Files.write(token, "token-2".getBytes(StandardCharsets.UTF_8));
+    // Make sure the modification time changes, even on file systems with a 
coarse resolution.
+    Files.setLastModifiedTime(
+        token, FileTime.fromMillis(Files.getLastModifiedTime(token).toMillis() 
+ 1000));
+    Assertions.assertEquals("token-2", provider.getToken());
+
+    // The token read before is kept while the file is empty or missing, as it 
is being rewritten.
+    Files.write(token, new byte[0]);
+    Files.setLastModifiedTime(
+        token, FileTime.fromMillis(Files.getLastModifiedTime(token).toMillis() 
+ 2000));
+    Assertions.assertEquals("token-2", provider.getToken());
+    Files.delete(token);
+    Assertions.assertEquals("token-2", provider.getToken());
+
+    Files.write(token, "token-3".getBytes(StandardCharsets.UTF_8));
+    Assertions.assertEquals("token-3", provider.getToken());
+  }
+
+  @Test
+  public void testTokenRotationWithinOneModificationTime() throws IOException {
+    // The file is emptied and rewritten within one tick of a coarse file 
system clock.
+    Path token = Files.write(dir.resolve("token"), 
"token-1".getBytes(StandardCharsets.UTF_8));
+    K8sClientUtils.FileTokenProvider provider = new 
K8sClientUtils.FileTokenProvider(token);
+    Assertions.assertEquals("token-1", provider.getToken());
+
+    FileTime rotatedAt = 
FileTime.fromMillis(Files.getLastModifiedTime(token).toMillis() + 1000);
+    Files.write(token, new byte[0]);
+    Files.setLastModifiedTime(token, rotatedAt);
+    Assertions.assertEquals("token-1", provider.getToken());
+
+    Files.write(token, "token-2".getBytes(StandardCharsets.UTF_8));
+    Files.setLastModifiedTime(token, rotatedAt);
+    Assertions.assertEquals("token-2", provider.getToken());
+  }
+
+  @Test
+  public void testUnreadableTokenFile() throws IOException {
+    Path empty = Files.write(dir.resolve("empty"), new byte[0]);
+    Assertions.assertThrows(
+        IllegalStateException.class, new 
K8sClientUtils.FileTokenProvider(empty)::getToken);
+    Assertions.assertThrows(
+        UncheckedIOException.class,
+        new 
K8sClientUtils.FileTokenProvider(dir.resolve("missing"))::getToken);
+  }
+}
diff --git 
a/plugins/k8s-job-executor/src/test/java/org/apache/gravitino/job/k8s/TestK8sJobExecutionId.java
 
b/plugins/k8s-job-executor/src/test/java/org/apache/gravitino/job/k8s/TestK8sJobExecutionId.java
new file mode 100644
index 0000000000..3103a051cc
--- /dev/null
+++ 
b/plugins/k8s-job-executor/src/test/java/org/apache/gravitino/job/k8s/TestK8sJobExecutionId.java
@@ -0,0 +1,76 @@
+/*
+ * 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 org.apache.gravitino.exceptions.NoSuchJobException;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class TestK8sJobExecutionId {
+
+  @Test
+  public void testFormatAndParse() {
+    K8sJobExecutionId id =
+        K8sJobExecutionId.of(
+            "default", K8sJobExecutionId.Kind.SPARK_APPLICATION, "jobs", 
"gravitino-job-1");
+    Assertions.assertEquals("default/sparkapp/jobs/gravitino-job-1", 
id.toString());
+
+    K8sJobExecutionId parsed = K8sJobExecutionId.parse(id.toString());
+    Assertions.assertEquals(id, parsed);
+    Assertions.assertEquals(id.hashCode(), parsed.hashCode());
+    Assertions.assertEquals("default", parsed.cluster());
+    Assertions.assertEquals(K8sJobExecutionId.Kind.SPARK_APPLICATION, 
parsed.kind());
+    Assertions.assertEquals("jobs", parsed.namespace());
+    Assertions.assertEquals("gravitino-job-1", parsed.name());
+  }
+
+  @Test
+  public void testKind() {
+    Assertions.assertEquals("sparkapp", 
K8sJobExecutionId.Kind.SPARK_APPLICATION.id());
+    Assertions.assertEquals(
+        K8sJobExecutionId.Kind.SPARK_APPLICATION, 
K8sJobExecutionId.Kind.fromId("sparkapp"));
+    Assertions.assertThrows(
+        IllegalArgumentException.class, () -> 
K8sJobExecutionId.Kind.fromId("job"));
+  }
+
+  @Test
+  public void testInvalid() {
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () -> K8sJobExecutionId.of("a/b", 
K8sJobExecutionId.Kind.SPARK_APPLICATION, "ns", "n"));
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () -> K8sJobExecutionId.of("default", 
K8sJobExecutionId.Kind.SPARK_APPLICATION, "", "n"));
+    Assertions.assertThrows(
+        IllegalArgumentException.class, () -> K8sJobExecutionId.of("default", 
null, "ns", "n"));
+
+    for (String invalid :
+        new String[] {
+          null,
+          "",
+          "ns/name",
+          "sparkapp/ns/name",
+          "default/sparkapp/ns/",
+          "a/b/c/d/e",
+          "default/job/ns/name"
+        }) {
+      Assertions.assertThrows(NoSuchJobException.class, () -> 
K8sJobExecutionId.parse(invalid));
+    }
+  }
+}
diff --git 
a/plugins/k8s-job-executor/src/test/java/org/apache/gravitino/job/k8s/TestK8sJobExecutorConfigs.java
 
b/plugins/k8s-job-executor/src/test/java/org/apache/gravitino/job/k8s/TestK8sJobExecutorConfigs.java
new file mode 100644
index 0000000000..bf18938f7c
--- /dev/null
+++ 
b/plugins/k8s-job-executor/src/test/java/org/apache/gravitino/job/k8s/TestK8sJobExecutorConfigs.java
@@ -0,0 +1,197 @@
+/*
+ * 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.collect.ImmutableMap;
+import java.util.HashMap;
+import java.util.Map;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class TestK8sJobExecutorConfigs {
+
+  public static Map<String, String> requiredConfigs() {
+    return ImmutableMap.of(
+        K8sJobExecutorConfigs.NAMESPACE, "default",
+        K8sJobExecutorConfigs.SPARK_IMAGE, "gravitino-spark:3.5.9",
+        K8sJobExecutorConfigs.SPARK_VERSION, "3.5.9");
+  }
+
+  @Test
+  public void testDefaults() {
+    K8sJobExecutorConfigs configs = new 
K8sJobExecutorConfigs(requiredConfigs());
+
+    Assertions.assertEquals("default", configs.cluster());
+    Assertions.assertNull(configs.kubeconfig());
+    Assertions.assertNull(configs.context());
+    Assertions.assertNull(configs.masterUrl());
+    Assertions.assertEquals("default", configs.namespace());
+    Assertions.assertEquals("gravitino-job", configs.namePrefix());
+    Assertions.assertEquals(5_000, configs.connectionTimeoutMs());
+    Assertions.assertEquals(10_000, configs.requestTimeoutMs());
+    Assertions.assertEquals(2, configs.requestRetries());
+    Assertions.assertEquals(5_000L, configs.statusCacheTtlMs());
+    Assertions.assertEquals(600_000L, configs.noStatusTimeoutMs());
+    Assertions.assertEquals("gravitino-spark:3.5.9", configs.sparkImage());
+    Assertions.assertEquals("3.5.9", configs.sparkVersion());
+    Assertions.assertEquals("spark", configs.sparkServiceAccount());
+    Assertions.assertEquals(86_400_000L, 
configs.sparkResourceRetainDurationMs());
+    Assertions.assertEquals(604_800_000L, configs.sparkTtlAfterStopMs());
+    Assertions.assertEquals(3_600_000L, configs.sparkDriverStartTimeoutMs());
+    Assertions.assertEquals(3_600_000L, configs.sparkDriverReadyTimeoutMs());
+    Assertions.assertTrue(configs.sparkConf().isEmpty());
+  }
+
+  @Test
+  public void testCustomValues() {
+    Map<String, String> map = new HashMap<>(requiredConfigs());
+    map.put(K8sJobExecutorConfigs.CLUSTER, "prod");
+    map.put(K8sJobExecutorConfigs.NAMESPACE, " jobs ");
+    map.put(K8sJobExecutorConfigs.NAME_PREFIX, "gvt-a");
+    map.put(K8sJobExecutorConfigs.STATUS_CACHE_TTL_MS, "0");
+    map.put(K8sJobExecutorConfigs.SPARK_TTL_AFTER_STOP_MS, "1000");
+    map.put("spark.conf.spark.executor.instances", "2");
+    map.put("spark.conf.", "ignored");
+
+    K8sJobExecutorConfigs configs = new K8sJobExecutorConfigs(map);
+
+    Assertions.assertEquals("prod", configs.cluster());
+    Assertions.assertEquals("jobs", configs.namespace());
+    Assertions.assertEquals("gvt-a", configs.namePrefix());
+    Assertions.assertEquals(0L, configs.statusCacheTtlMs());
+    Assertions.assertEquals(1000L, configs.sparkTtlAfterStopMs());
+    Assertions.assertEquals(ImmutableMap.of("spark.executor.instances", "2"), 
configs.sparkConf());
+  }
+
+  @Test
+  public void testDurationKeys() {
+    // The duration keys end with InMs, like the other job configurations of 
Gravitino.
+    Map<String, String> map = new HashMap<>(requiredConfigs());
+    map.put("connectionTimeoutInMs", "7");
+    map.put("requestTimeoutInMs", "8");
+    map.put("requestRetries", "0");
+    map.put("statusCacheTtlInMs", "1");
+    map.put("noStatusTimeoutInMs", "2");
+    map.put("spark.resourceRetainDurationInMs", "3");
+    map.put("spark.ttlAfterStopInMs", "4");
+    map.put("spark.driverStartTimeoutInMs", "5");
+    map.put("spark.driverReadyTimeoutInMs", "6");
+
+    K8sJobExecutorConfigs configs = new K8sJobExecutorConfigs(map);
+
+    Assertions.assertEquals(7, configs.connectionTimeoutMs());
+    Assertions.assertEquals(8, configs.requestTimeoutMs());
+    Assertions.assertEquals(0, configs.requestRetries());
+    Assertions.assertEquals(1L, configs.statusCacheTtlMs());
+    Assertions.assertEquals(2L, configs.noStatusTimeoutMs());
+    Assertions.assertEquals(3L, configs.sparkResourceRetainDurationMs());
+    Assertions.assertEquals(4L, configs.sparkTtlAfterStopMs());
+    Assertions.assertEquals(5L, configs.sparkDriverStartTimeoutMs());
+    Assertions.assertEquals(6L, configs.sparkDriverReadyTimeoutMs());
+  }
+
+  @Test
+  public void testRequiredConfigs() {
+    for (String required :
+        new String[] {
+          K8sJobExecutorConfigs.NAMESPACE,
+          K8sJobExecutorConfigs.SPARK_IMAGE,
+          K8sJobExecutorConfigs.SPARK_VERSION
+        }) {
+      Map<String, String> map = new HashMap<>(requiredConfigs());
+      map.remove(required);
+      IllegalArgumentException e =
+          Assertions.assertThrows(
+              IllegalArgumentException.class, () -> new 
K8sJobExecutorConfigs(map), required);
+      Assertions.assertEquals(required + " must be set", e.getMessage());
+
+      map.put(required, " ");
+      Assertions.assertThrows(
+          IllegalArgumentException.class, () -> new 
K8sJobExecutorConfigs(map), required);
+    }
+  }
+
+  @Test
+  public void testConnectionConfigs() {
+    assertInvalid(
+        K8sJobExecutorConfigs.MASTER_URL,
+        "https://k8s:6443";,
+        K8sJobExecutorConfigs.KUBECONFIG,
+        "/kube/config");
+    assertInvalid(
+        K8sJobExecutorConfigs.MASTER_URL,
+        "https://k8s:6443";,
+        K8sJobExecutorConfigs.CONTEXT,
+        "prod");
+    assertInvalid(K8sJobExecutorConfigs.TOKEN_FILE, "/token");
+    assertInvalid(K8sJobExecutorConfigs.CA_CERT_FILE, "/ca.crt");
+
+    // A master URL without a scheme would let the client fall back to plain 
HTTP, and a token
+    // must never be sent over it.
+    assertInvalid(K8sJobExecutorConfigs.MASTER_URL, "k8s:6443");
+    assertInvalid(K8sJobExecutorConfigs.MASTER_URL, "ftp://k8s:6443";);
+    assertInvalid(
+        K8sJobExecutorConfigs.MASTER_URL,
+        "http://k8s:6443";,
+        K8sJobExecutorConfigs.TOKEN_FILE,
+        "/token");
+    // A CA certificate would never be used with plain HTTP.
+    assertInvalid(
+        K8sJobExecutorConfigs.MASTER_URL,
+        "http://k8s:6443";,
+        K8sJobExecutorConfigs.CA_CERT_FILE,
+        "/ca.crt");
+    Map<String, String> http = new HashMap<>(requiredConfigs());
+    http.put(K8sJobExecutorConfigs.MASTER_URL, "http://localhost:8001";);
+    Assertions.assertEquals("http://localhost:8001";, new 
K8sJobExecutorConfigs(http).masterUrl());
+
+    Map<String, String> map = new HashMap<>(requiredConfigs());
+    map.put(K8sJobExecutorConfigs.MASTER_URL, "https://k8s:6443";);
+    map.put(K8sJobExecutorConfigs.CA_CERT_FILE, "/ca.crt");
+    map.put(K8sJobExecutorConfigs.TOKEN_FILE, "/token");
+    K8sJobExecutorConfigs configs = new K8sJobExecutorConfigs(map);
+    Assertions.assertEquals("https://k8s:6443";, configs.masterUrl());
+    Assertions.assertEquals("/ca.crt", configs.caCertFile());
+    Assertions.assertEquals("/token", configs.tokenFile());
+  }
+
+  @Test
+  public void testInvalidValues() {
+    assertInvalid(K8sJobExecutorConfigs.CLUSTER, "a/b");
+    assertInvalid(K8sJobExecutorConfigs.NAMESPACE, "Gravitino_Jobs");
+    assertInvalid(K8sJobExecutorConfigs.NAME_PREFIX, "Gravitino");
+    assertInvalid(K8sJobExecutorConfigs.NAME_PREFIX, "gravitino-");
+    assertInvalid(K8sJobExecutorConfigs.NAME_PREFIX, 
"a-name-prefix-that-is-too-long");
+    assertInvalid(K8sJobExecutorConfigs.NO_STATUS_TIMEOUT_MS, "0");
+    assertInvalid(K8sJobExecutorConfigs.CONNECTION_TIMEOUT_MS, "0");
+    assertInvalid(K8sJobExecutorConfigs.REQUEST_TIMEOUT_MS, "-1");
+    assertInvalid(K8sJobExecutorConfigs.REQUEST_TIMEOUT_MS, "3000000000");
+    assertInvalid(K8sJobExecutorConfigs.REQUEST_RETRIES, "-1");
+    assertInvalid(K8sJobExecutorConfigs.REQUEST_RETRIES, "many");
+    assertInvalid(K8sJobExecutorConfigs.SPARK_DRIVER_START_TIMEOUT_MS, "1h");
+  }
+
+  private static void assertInvalid(String... keyValues) {
+    Map<String, String> map = new HashMap<>(requiredConfigs());
+    for (int i = 0; i < keyValues.length; i += 2) {
+      map.put(keyValues[i], keyValues[i + 1]);
+    }
+    Assertions.assertThrows(IllegalArgumentException.class, () -> new 
K8sJobExecutorConfigs(map));
+  }
+}
diff --git 
a/plugins/k8s-job-executor/src/test/java/org/apache/gravitino/job/k8s/TestK8sJobResourceUtils.java
 
b/plugins/k8s-job-executor/src/test/java/org/apache/gravitino/job/k8s/TestK8sJobResourceUtils.java
new file mode 100644
index 0000000000..76c7661738
--- /dev/null
+++ 
b/plugins/k8s-job-executor/src/test/java/org/apache/gravitino/job/k8s/TestK8sJobResourceUtils.java
@@ -0,0 +1,85 @@
+/*
+ * 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.collect.ImmutableMap;
+import io.fabric8.kubernetes.api.model.ConfigMap;
+import io.fabric8.kubernetes.api.model.ConfigMapBuilder;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class TestK8sJobResourceUtils {
+
+  @Test
+  public void testResourceName() {
+    K8sJobExecutorConfigs configs =
+        new K8sJobExecutorConfigs(TestK8sJobExecutorConfigs.requiredConfigs());
+    Assertions.assertEquals("gravitino-job-42", 
K8sJobResourceUtils.resourceName(configs, 42L));
+  }
+
+  @Test
+  public void testJobLabels() {
+    Assertions.assertEquals(
+        ImmutableMap.of(
+            "app.kubernetes.io/managed-by", "gravitino", 
"gravitino.apache.org/job-id", "42"),
+        K8sJobResourceUtils.jobLabels(42L));
+
+    ConfigMap resource =
+        new ConfigMapBuilder()
+            .withNewMetadata()
+            .withLabels(K8sJobResourceUtils.jobLabels(42L))
+            .endMetadata()
+            .build();
+    Assertions.assertTrue(K8sJobResourceUtils.isOfJob(resource, 42L));
+    Assertions.assertFalse(K8sJobResourceUtils.isOfJob(resource, 43L));
+    Assertions.assertFalse(
+        K8sJobResourceUtils.isOfJob(
+            new ConfigMapBuilder().withNewMetadata().endMetadata().build(), 
42L));
+    Assertions.assertFalse(K8sJobResourceUtils.isOfJob(new ConfigMap(), 42L));
+  }
+
+  @Test
+  public void testIsCancelRequested() {
+    Assertions.assertFalse(
+        K8sJobResourceUtils.isCancelRequested(
+            new ConfigMapBuilder().withNewMetadata().endMetadata().build()));
+    Assertions.assertFalse(K8sJobResourceUtils.isCancelRequested(new 
ConfigMap()));
+    
Assertions.assertFalse(K8sJobResourceUtils.isCancelRequested(annotated("false")));
+    
Assertions.assertTrue(K8sJobResourceUtils.isCancelRequested(annotated("true")));
+  }
+
+  @Test
+  public void testIsValidNamespace() {
+    for (String valid : new String[] {"default", "team-a", "a", "ns1", "1ns"}) 
{
+      Assertions.assertTrue(K8sJobResourceUtils.isValidNamespace(valid), 
valid);
+    }
+    for (String invalid :
+        new String[] {null, "", "Team", "team_a", "-team", "team-", "a/b", 
"a".repeat(64)}) {
+      Assertions.assertFalse(K8sJobResourceUtils.isValidNamespace(invalid), 
invalid);
+    }
+  }
+
+  private static ConfigMap annotated(String cancelRequested) {
+    return new ConfigMapBuilder()
+        .withNewMetadata()
+        .addToAnnotations(K8sJobResourceUtils.ANNOTATION_CANCEL_REQUESTED, 
cancelRequested)
+        .endMetadata()
+        .build();
+  }
+}
diff --git a/settings.gradle.kts b/settings.gradle.kts
index ced9feab65..edc3da371e 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -127,6 +127,7 @@ include(":catalogs:hadoop-auth")
 include(":lineage")
 include(":mcp-server")
 include(":plugins:idp-basic")
+include(":plugins:k8s-job-executor")
 include(":plugins:tms")
 include(
   ":maintenance:optimizer-api",

Reply via email to