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 7555479b9d [#13617] feat(k8s-job-executor): Pass the environment 
variables of a job template to the Spark driver and executors (#13948)
7555479b9d is described below

commit 7555479b9d38cf3f2294ebe4fe4fe3e84dd1dd48
Author: Jerry Shao <[email protected]>
AuthorDate: Tue Oct 20 11:47:26 2026 +0800

    [#13617] feat(k8s-job-executor): Pass the environment variables of a job 
template to the Spark driver and executors (#13948)
    
    ### What changes were proposed in this pull request?
    
    The `k8s` job executor rejected Spark job templates with environment
    variables. It now passes them to the Spark driver and executors.
    
    - Each environment variable `X` of the job template becomes the Spark
    configurations `spark.kubernetes.driverEnv.X` and `spark.executorEnv.X`.
    - They override the default Spark configurations of the executor
    (`gravitino.jobExecutor.k8s.spark.conf.*`), and the Spark configurations
    of the job template override them, so a template can still set a
    different value for the driver or the executors.
    - A name must match `[-._a-zA-Z][-._a-zA-Z0-9]*`, must not be `.` and
    must not start with `..`, otherwise the job is rejected when it is
    submitted. Spark 3.5 silently drops any other `spark.executorEnv.*` name
    for the executors while the driver still gets it, and Kubernetes without
    the relaxed environment variable validation rejects such a name when the
    operator creates the driver pod. The error message names the variable,
    never its value.
    
    The values are readable in the SparkApplication and in its pods. A
    sensitive value can come from a Secret created beforehand, referenced in
    the Spark configurations with
    `spark.kubernetes.driver.secretKeyRef.<name>` and
    `spark.kubernetes.executor.secretKeyRef.<name>`, which needs no change
    in Gravitino. #13618 documents both.
    
    ### Why are the changes needed?
    
    A job template with environment variables runs on the `local` job
    executor but was rejected by the `k8s` one.
    
    Fix: #13617
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes. Spark job templates with environment variables run on the `k8s` job
    executor, and an invalid variable name is rejected with an error. No new
    configuration.
    
    ### How was this patch tested?
    
    - Unit tests for the mapping, the precedence between the executor
    defaults, the environment variables and the template configurations, and
    the rejected names.
    - Manually, with a Gravitino server started from the distribution
    package of this branch against a local Kubernetes cluster (OrbStack)
    with spark-kubernetes-operator 0.9.0, through the REST API: a Spark Pi
    template with environment variables, one of them from a placeholder,
    succeeded. The values were checked in the pod specs and in the processes
    of the driver and the executor, including a name with a dot, a server
    default overridden by the template, a template configuration overriding
    a variable for the executors only, and a variable from a Secret through
    `secretKeyRef` in the template configurations. A template with the
    variable name `MY VAR` was rejected with a 400.
    
    🤖 Generated with [Claude Code](https://claude.com/claude-code)
    
    ---------
    
    Co-authored-by: Claude Opus 5.5 <[email protected]>
---
 .../job/k8s/spark/SparkApplicationUtils.java       | 46 +++++++++++---
 .../job/k8s/spark/TestSparkApplicationUtils.java   | 72 +++++++++++++++++++---
 2 files changed, 101 insertions(+), 17 deletions(-)

diff --git 
a/plugins/k8s-job-executor/src/main/java/org/apache/gravitino/job/k8s/spark/SparkApplicationUtils.java
 
b/plugins/k8s-job-executor/src/main/java/org/apache/gravitino/job/k8s/spark/SparkApplicationUtils.java
index 1a10510520..8ce2a8a6d2 100644
--- 
a/plugins/k8s-job-executor/src/main/java/org/apache/gravitino/job/k8s/spark/SparkApplicationUtils.java
+++ 
b/plugins/k8s-job-executor/src/main/java/org/apache/gravitino/job/k8s/spark/SparkApplicationUtils.java
@@ -86,6 +86,15 @@ public final class SparkApplicationUtils {
   private static final String SPARK_JARS = "spark.jars";
   private static final String SPARK_FILES = "spark.files";
   private static final String SPARK_ARCHIVES = "spark.archives";
+  private static final String SPARK_DRIVER_ENV_PREFIX = 
"spark.kubernetes.driverEnv.";
+  private static final String SPARK_EXECUTOR_ENV_PREFIX = "spark.executorEnv.";
+
+  // The environment variable names that Spark passes to the executors: Spark 
3.5 drops any other
+  // spark.executorEnv.* key with only a warning, while the driver would still 
get it. Kubernetes
+  // without the relaxed environment variable validation accepts the same 
names, except '.' and
+  // the names starting with '..', which are rejected separately.
+  private static final Pattern ENVIRONMENT_NAME_PATTERN =
+      Pattern.compile("[-._a-zA-Z][-._a-zA-Z0-9]*");
 
   private static final Pattern URI_USER_INFO = 
Pattern.compile("(?<=://)[^/?#@]*@");
 
@@ -98,6 +107,12 @@ public final class SparkApplicationUtils {
    * the Gravitino server, runs the jar of {@link 
K8sJobExecutorConfigs#SPARK_BUILTIN_JOBS_JAR}
    * instead.
    *
+   * <p>The environment variables of the job template are set for the driver 
and the executors
+   * through the Spark configurations {@code spark.kubernetes.driverEnv.*} and 
{@code
+   * spark.executorEnv.*}, which means their values are visible in the 
SparkApplication and in its
+   * pods. They override the default Spark configurations of the job executor, 
and the Spark
+   * configurations of the job template override them.
+   *
    * @param context the context of the job run
    * @param template the runtime job template, whose resources are the URIs 
given in the template
    * @param configs the job executor configurations
@@ -106,15 +121,6 @@ public final class SparkApplicationUtils {
    */
   public static GenericKubernetesResource buildSparkApplication(
       JobContext context, SparkJobTemplate template, K8sJobExecutorConfigs 
configs) {
-    if (!template.environments().isEmpty()) {
-      throw new IllegalArgumentException(
-          String.format(
-              "Job template %s sets environment variables %s, which the k8s 
job executor doesn't"
-                  + " support yet. Pass them as Spark configurations instead, 
for example"
-                  + " spark.kubernetes.driverEnv.<name> and 
spark.executorEnv.<name>",
-              template.name(), template.environments().keySet()));
-    }
-
     String executable = getExecutableUri(context, template, configs);
     checkClusterReachable(executable);
     template.jars().forEach(SparkApplicationUtils::checkClusterReachable);
@@ -122,6 +128,14 @@ public final class SparkApplicationUtils {
     template.archives().forEach(SparkApplicationUtils::checkClusterReachable);
 
     Map<String, String> sparkConf = new TreeMap<>(configs.sparkConf());
+    template
+        .environments()
+        .forEach(
+            (name, value) -> {
+              checkEnvironmentName(template, name);
+              sparkConf.put(SPARK_DRIVER_ENV_PREFIX + name, value);
+              sparkConf.put(SPARK_EXECUTOR_ENV_PREFIX + name, value);
+            });
     sparkConf.putAll(template.configs());
     // The operator only supports cluster mode, so a job that asks for client 
mode is rejected
     // rather than silently run in cluster mode.
@@ -224,6 +238,20 @@ public final class SparkApplicationUtils {
     return template.executable();
   }
 
+  private static void checkEnvironmentName(SparkJobTemplate template, String 
name) {
+    // Checked here, as an invalid name would otherwise only miss the 
executors, or fail the job
+    // when the operator creates the driver pod.
+    if (!ENVIRONMENT_NAME_PATTERN.matcher(name).matches()
+        || ".".equals(name)
+        || name.startsWith("..")) {
+      throw new IllegalArgumentException(
+          String.format(
+              "Environment variable name '%s' of job template %s is invalid: 
it must match %s,"
+                  + " must not be '.' and must not start with '..'",
+              name, template.name(), ENVIRONMENT_NAME_PATTERN.pattern()));
+    }
+  }
+
   /**
    * Checks that a resource of the job, or each of them if it is a 
comma-separated list as Spark
    * accepts, is a URI that the cluster can reach rather than a path on the 
Gravitino server.
diff --git 
a/plugins/k8s-job-executor/src/test/java/org/apache/gravitino/job/k8s/spark/TestSparkApplicationUtils.java
 
b/plugins/k8s-job-executor/src/test/java/org/apache/gravitino/job/k8s/spark/TestSparkApplicationUtils.java
index fdb6b2e65b..39da670461 100644
--- 
a/plugins/k8s-job-executor/src/test/java/org/apache/gravitino/job/k8s/spark/TestSparkApplicationUtils.java
+++ 
b/plugins/k8s-job-executor/src/test/java/org/apache/gravitino/job/k8s/spark/TestSparkApplicationUtils.java
@@ -514,20 +514,76 @@ public class TestSparkApplicationUtils {
   }
 
   @Test
-  public void testRejectEnvironments() {
+  @SuppressWarnings("unchecked")
+  public void testEnvironments() {
+    K8sJobExecutorConfigs withDefaults =
+        newConfigs(
+            ImmutableMap.of(
+                "spark.conf.spark.kubernetes.driverEnv.REGION",
+                "default-region",
+                "spark.conf.spark.executorEnv.STAGE",
+                "default-stage"));
     SparkJobTemplate template =
         SparkJobTemplate.builder()
             .withName("user-job")
             .withExecutable("https://repo/app.jar";)
             .withClassName("org.example.App")
-            .withEnvironments(ImmutableMap.of("TOKEN", "secret"))
+            .withEnvironments(ImmutableMap.of("REGION", "us-west", 
"app.mode-1", "", "TZ", "UTC"))
+            .withConfigs(ImmutableMap.of("spark.executorEnv.TZ", 
"Asia/Shanghai"))
             .build();
-    IllegalArgumentException e =
-        Assertions.assertThrows(
-            IllegalArgumentException.class,
-            () -> SparkApplicationUtils.buildSparkApplication(CONTEXT, 
template, configs));
-    Assertions.assertTrue(e.getMessage().contains("TOKEN"));
-    Assertions.assertFalse(e.getMessage().contains("secret"));
+
+    Map<String, String> sparkConf =
+        (Map<String, String>) getSpec(template, withDefaults).get("sparkConf");
+
+    // Set for the driver and the executors, in place of the defaults of the 
job executor.
+    Assertions.assertEquals("us-west", 
sparkConf.get("spark.kubernetes.driverEnv.REGION"));
+    Assertions.assertEquals("us-west", 
sparkConf.get("spark.executorEnv.REGION"));
+    Assertions.assertEquals("", 
sparkConf.get("spark.kubernetes.driverEnv.app.mode-1"));
+    Assertions.assertEquals("", sparkConf.get("spark.executorEnv.app.mode-1"));
+    // The Spark configurations of the job template take precedence.
+    Assertions.assertEquals("UTC", 
sparkConf.get("spark.kubernetes.driverEnv.TZ"));
+    Assertions.assertEquals("Asia/Shanghai", 
sparkConf.get("spark.executorEnv.TZ"));
+    // A default that the job template doesn't set is kept.
+    Assertions.assertEquals("default-stage", 
sparkConf.get("spark.executorEnv.STAGE"));
+  }
+
+  @Test
+  @SuppressWarnings("unchecked")
+  public void testAcceptEnvironmentNames() {
+    for (String valid : new String[] {"-X", ".X", "_X", "a.b-c_1", ".a.."}) {
+      SparkJobTemplate template =
+          SparkJobTemplate.builder()
+              .withName("user-job")
+              .withExecutable("https://repo/app.jar";)
+              .withClassName("org.example.App")
+              .withEnvironments(ImmutableMap.of(valid, "v"))
+              .build();
+      Map<String, String> sparkConf =
+          (Map<String, String>) getSpec(template, configs).get("sparkConf");
+      Assertions.assertEquals("v", sparkConf.get("spark.kubernetes.driverEnv." 
+ valid), valid);
+      Assertions.assertEquals("v", sparkConf.get("spark.executorEnv." + 
valid), valid);
+    }
+  }
+
+  @Test
+  public void testRejectInvalidEnvironmentName() {
+    for (String invalid : new String[] {"", "MY VAR", "A=B", "1ST", "PATH/X", 
".", "..", "..CFG"}) {
+      SparkJobTemplate template =
+          SparkJobTemplate.builder()
+              .withName("user-job")
+              .withExecutable("https://repo/app.jar";)
+              .withClassName("org.example.App")
+              .withEnvironments(ImmutableMap.of(invalid, "secret-value"))
+              .build();
+      IllegalArgumentException e =
+          Assertions.assertThrows(
+              IllegalArgumentException.class,
+              () -> SparkApplicationUtils.buildSparkApplication(CONTEXT, 
template, configs));
+      Assertions.assertTrue(e.getMessage().contains("user-job"), 
e.getMessage());
+      Assertions.assertTrue(e.getMessage().contains("'" + invalid + "'"), 
e.getMessage());
+      // The value of an environment variable never goes into an error message.
+      Assertions.assertFalse(e.getMessage().contains("secret-value"), 
e.getMessage());
+    }
   }
 
   public static K8sJobExecutorConfigs newConfigs(Map<String, String> extra) {

Reply via email to