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

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


The following commit(s) were added to refs/heads/main by this push:
     new 84d69df701 Issue #8495 : Optionally resolve OS environment variables 
as Hop variables (#8638)
84d69df701 is described below

commit 84d69df701dcc02ac28ad3f2493965924e06cccb
Author: vbhanuchander-lang <[email protected]>
AuthorDate: Mon Oct 19 03:29:02 2026 -0400

    Issue #8495 : Optionally resolve OS environment variables as Hop variables 
(#8638)
    
    * Issue #8495 : Optionally resolve OS environment variables as Hop variables
    
    Variables.initializeFrom seeds the variable space from 
System.getProperties()
    and the described variables in hop-config.json, but never from
    System.getenv(). An exported environment variable is therefore not 
resolvable,
    so a project Home folder of ${PROJECT_HOME_PATH} stays literal even though
    ProjectConfig does call variables.resolve() on it.
    
    HOP_IMPORT_ENVIRONMENT_VARIABLES, off by default, makes the environment 
part of
    the variable space. It is read through HopResolvedSettings so the flag 
itself
    can be set from the environment, which is what a container deployment has to
    hand.
    
    Off by default because the whole environment becomes visible as variables 
when
    it is on, which is the bleed-over Matt raised on the issue. Turning it on 
is a
    deliberate act by whoever runs Hop.
    
    The environment is seeded before everything else, so it has the lowest
    precedence: a name also set with -D, in hop-config.json, by a parent or by
    injection keeps the value it resolves to today, and the environment only
    supplies names nothing else defines. No existing configuration changes
    behaviour, whether the flag is on or off.
    
    That ordering also matches HopResolvedSettings, which already resolves
    hop-config then environment then -D, and whose javadoc notes that the 
variable
    space is not yet aligned with it.
    
    Three tests: the environment stays out by default, ${NAME} resolves an 
exported
    variable when the flag is on, and -D still wins over the environment. The 
second
    fails without this change.
    
    * Issue #8495 : Let an exported HOP_IMPORT_ENVIRONMENT_VARIABLES win over 
the seeded default
    
    HopEnvironment copies the hop-config value of every described variable onto
    the system properties, so HopResolvedSettings.resolveString always saw the
    default N in the JVM layer and an exported flag was never read. A system
    property now only counts as a -D override when it differs from the 
hop-config
    value. The decision takes the three layers as arguments so it can be tested
    without changing the process environment.
---
 core/src/main/java/org/apache/hop/core/Const.java  |  14 +++
 .../org/apache/hop/core/variables/Variables.java   |  47 ++++++++
 .../apache/hop/core/variables/VariablesTest.java   | 128 +++++++++++++++++++++
 3 files changed, 189 insertions(+)

diff --git a/core/src/main/java/org/apache/hop/core/Const.java 
b/core/src/main/java/org/apache/hop/core/Const.java
index 79ace80d08..0aeadf8c8f 100644
--- a/core/src/main/java/org/apache/hop/core/Const.java
+++ b/core/src/main/java/org/apache/hop/core/Const.java
@@ -828,6 +828,20 @@ public class Const {
           "Set this variable to 'Y' to disable standard Hop logging to the 
console. (stdout)")
   public static final String HOP_DISABLE_CONSOLE_LOGGING = 
"HOP_DISABLE_CONSOLE_LOGGING";
 
+  /**
+   * Set this variable to Y to make the operating system environment available 
as Hop variables, so
+   * that ${NAME} resolves an exported environment variable. Off by default, 
because the whole
+   * environment becomes visible as variables when it is on. Environment 
variables have the lowest
+   * precedence: anything set with -D, in hop-config.json, or by a parent 
keeps its current value.
+   */
+  @Variable(
+      value = "N",
+      description =
+          "Set this variable to 'Y' to make operating system environment 
variables available as Hop"
+              + " variables, so that ${NAME} resolves an exported environment 
variable. They have"
+              + " the lowest precedence: anything set with -D or in 
hop-config.json wins.")
+  public static final String HOP_IMPORT_ENVIRONMENT_VARIABLES = 
"HOP_IMPORT_ENVIRONMENT_VARIABLES";
+
   /** The name of the variable containing an alternative default number format 
*/
   @Variable(
       description = "The name of the variable containing an alternative 
default number format")
diff --git a/core/src/main/java/org/apache/hop/core/variables/Variables.java 
b/core/src/main/java/org/apache/hop/core/variables/Variables.java
index 3690ff9e9a..0afaa39d17 100644
--- a/core/src/main/java/org/apache/hop/core/variables/Variables.java
+++ b/core/src/main/java/org/apache/hop/core/variables/Variables.java
@@ -26,6 +26,7 @@ import java.util.Set;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.hop.core.Const;
 import org.apache.hop.core.config.HopConfig;
+import org.apache.hop.core.config.HopResolvedSettings;
 import org.apache.hop.core.exception.HopException;
 import org.apache.hop.core.exception.HopRuntimeException;
 import org.apache.hop.core.exception.HopValueException;
@@ -112,6 +113,15 @@ public class Variables implements IVariables {
   public void initializeFrom(IVariables parent) {
     this.parent = parent;
 
+    // The operating system environment, only when explicitly enabled. It is 
seeded before
+    // everything else so that it has the lowest precedence: a name that is 
also set with -D, in
+    // hop-config.json, by a parent or by injection keeps the value it 
resolves to today, and the
+    // environment only supplies names nothing else defines.
+    //
+    if (isEnvironmentImported()) {
+      getProperties().putAll(System.getenv());
+    }
+
     // Clone the system properties to avoid ConcurrentModificationException 
while iterating
     // and then add all of them to properties variable.
     //
@@ -135,6 +145,43 @@ public class Variables implements IVariables {
     initialized = true;
   }
 
+  /**
+   * Whether the operating system environment should be made available as Hop 
variables, read from
+   * the three places the flag can be set.
+   */
+  private static boolean isEnvironmentImported() {
+    String name = Const.HOP_IMPORT_ENVIRONMENT_VARIABLES;
+    return isEnvironmentImported(
+        HopConfig.readStringVariable(name, null), System.getenv(name), 
System.getProperty(name));
+  }
+
+  /**
+   * Decides whether the environment is imported, following the hop-config, 
environment, {@code -D}
+   * precedence of {@link HopResolvedSettings} so that the flag itself can be 
exported, which is
+   * what a container deployment has to hand.
+   *
+   * <p>{@link HopResolvedSettings#resolveString} cannot be used directly: 
once {@code
+   * HopEnvironment} has started it copies the hop-config value (the 
annotation default {@code N}
+   * unless configured) onto the system properties, so the JVM layer would 
always win and an
+   * exported flag would never be seen. A system property therefore only 
counts as a {@code -D}
+   * override when it differs from the hop-config value. An explicit {@code 
-D} that repeats the
+   * hop-config value cannot be told apart from that copy, so an exported 
value wins over it.
+   *
+   * @param configValue the value in hop-config.json, or null when it is not 
configured
+   * @param environmentValue the exported environment variable, or null when 
it is not set
+   * @param systemPropertyValue the system property, or null when it is not set
+   */
+  static boolean isEnvironmentImported(
+      String configValue, String environmentValue, String systemPropertyValue) 
{
+    String value = configValue;
+    if (systemPropertyValue != null && 
!systemPropertyValue.equals(Const.NVL(configValue, ""))) {
+      value = systemPropertyValue;
+    } else if (environmentValue != null) {
+      value = environmentValue;
+    }
+    return "Y".equalsIgnoreCase(value);
+  }
+
   @Override
   public String[] getVariableNames() {
     Set<String> keySet = properties.keySet();
diff --git 
a/core/src/test/java/org/apache/hop/core/variables/VariablesTest.java 
b/core/src/test/java/org/apache/hop/core/variables/VariablesTest.java
index 42b0ce9952..65cb66207c 100644
--- a/core/src/test/java/org/apache/hop/core/variables/VariablesTest.java
+++ b/core/src/test/java/org/apache/hop/core/variables/VariablesTest.java
@@ -19,8 +19,10 @@ package org.apache.hop.core.variables;
 
 import static org.junit.jupiter.api.Assertions.assertArrayEquals;
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.Mockito.doAnswer;
@@ -30,15 +32,21 @@ import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
 import java.util.ArrayList;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import java.util.concurrent.Callable;
 import java.util.concurrent.Executors;
 import java.util.concurrent.Future;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hop.core.Const;
+import org.apache.hop.core.config.HopConfig;
 import org.apache.hop.core.exception.HopValueException;
 import org.apache.hop.core.row.RowMeta;
 import org.apache.hop.core.row.value.ValueMetaString;
 import org.apache.hop.metadata.api.IHopMetadataProvider;
+import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.Test;
 import org.mockito.invocation.InvocationOnMock;
 import org.mockito.stubbing.Answer;
@@ -196,4 +204,124 @@ class VariablesTest {
     assertEquals(provider, child.findExecutionMetadataProvider());
     assertNull(new Variables().findExecutionMetadataProvider());
   }
+
+  /**
+   * An environment entry that nothing else defines, so that what it resolves 
to is unambiguous.
+   * Names are sorted so the same entry is chosen on every run.
+   */
+  private static Map.Entry<String, String> 
environmentEntryNotDefinedElsewhere() {
+    Set<String> definedNames = new 
HashSet<>(System.getProperties().stringPropertyNames());
+    for (DescribedVariable describedVariable : 
HopConfig.getInstance().getDescribedVariables()) {
+      definedNames.add(describedVariable.getName());
+    }
+    return System.getenv().entrySet().stream()
+        .filter(entry -> !definedNames.contains(entry.getKey()))
+        .filter(entry -> StringUtils.isNotEmpty(entry.getValue()))
+        .min(Map.Entry.comparingByKey())
+        .orElse(null);
+  }
+
+  /** The flag must not already be exported, or the process environment 
decides these tests. */
+  private static void assumeFlagNotExported() {
+    assumeTrue(
+        System.getenv(Const.HOP_IMPORT_ENVIRONMENT_VARIABLES) == null,
+        Const.HOP_IMPORT_ENVIRONMENT_VARIABLES + " is exported in this 
environment");
+  }
+
+  @AfterEach
+  void clearEnvironmentImportFlag() {
+    System.clearProperty(Const.HOP_IMPORT_ENVIRONMENT_VARIABLES);
+  }
+
+  /** The operating system environment stays out of the variable space unless 
it is asked for. */
+  @Test
+  void environmentIsNotImportedByDefault() {
+    assumeFlagNotExported();
+    Map.Entry<String, String> entry = environmentEntryNotDefinedElsewhere();
+    assumeTrue(entry != null, "no usable environment variable to test with");
+
+    Variables variables = new Variables();
+    variables.initializeFrom(null);
+
+    assertNull(variables.getVariable(entry.getKey()));
+  }
+
+  /** With the flag on, ${NAME} resolves an exported environment variable 
(#8495). */
+  @Test
+  void environmentIsImportedWhenEnabled() {
+    assumeFlagNotExported();
+    Map.Entry<String, String> entry = environmentEntryNotDefinedElsewhere();
+    assumeTrue(entry != null, "no usable environment variable to test with");
+    System.setProperty(Const.HOP_IMPORT_ENVIRONMENT_VARIABLES, "Y");
+
+    Variables variables = new Variables();
+    variables.initializeFrom(null);
+
+    assertEquals(entry.getValue(), variables.getVariable(entry.getKey()));
+    assertEquals(entry.getValue(), variables.resolve("${" + entry.getKey() + 
"}"));
+  }
+
+  /**
+   * The environment has the lowest precedence, so a name that is also set 
with -D keeps the value
+   * it resolves to today.
+   */
+  @Test
+  void systemPropertiesWinOverTheEnvironment() {
+    assumeFlagNotExported();
+    Map.Entry<String, String> entry = environmentEntryNotDefinedElsewhere();
+    assumeTrue(entry != null, "no usable environment variable to test with");
+    assumeTrue(!"overridden-by-minus-D".equals(entry.getValue()));
+    System.setProperty(Const.HOP_IMPORT_ENVIRONMENT_VARIABLES, "Y");
+    System.setProperty(entry.getKey(), "overridden-by-minus-D");
+    try {
+      Variables variables = new Variables();
+      variables.initializeFrom(null);
+
+      assertEquals("overridden-by-minus-D", 
variables.getVariable(entry.getKey()));
+    } finally {
+      System.clearProperty(entry.getKey());
+    }
+  }
+
+  /** Nothing set anywhere: off. */
+  @Test
+  void environmentImportIsOffWhenNothingIsSet() {
+    assertFalse(Variables.isEnvironmentImported(null, null, null));
+  }
+
+  /**
+   * The container case after HopEnvironment has started: it copied the 
default N from hop-config
+   * onto the system properties, and the flag is exported as Y. The export 
must win (#8638).
+   */
+  @Test
+  void exportedFlagWinsOverTheSeededDefault() {
+    assertTrue(Variables.isEnvironmentImported("N", "Y", "N"));
+  }
+
+  /** Before HopEnvironment has started nothing is seeded, and an exported 
flag is enough. */
+  @Test
+  void exportedFlagIsEnoughBeforeHopEnvironmentStarts() {
+    assertTrue(Variables.isEnvironmentImported(null, "Y", null));
+  }
+
+  /** HopEnvironment seeds an empty string when hop-config has the name 
without a value. */
+  @Test
+  void exportedFlagWinsOverASeededEmptyValue() {
+    assertTrue(Variables.isEnvironmentImported(null, "Y", ""));
+  }
+
+  /** The environment overrides hop-config, as in HopResolvedSettings. */
+  @Test
+  void exportedFlagOverridesHopConfig() {
+    assertFalse(Variables.isEnvironmentImported("Y", "N", "Y"));
+    assertTrue(Variables.isEnvironmentImported("Y", null, "Y"));
+  }
+
+  /** A -D that differs from hop-config is an explicit override and wins over 
the environment. */
+  @Test
+  void minusDWinsOverTheEnvironment() {
+    assertTrue(Variables.isEnvironmentImported("N", "N", "Y"));
+    assertFalse(Variables.isEnvironmentImported("Y", "Y", "N"));
+    assertTrue(Variables.isEnvironmentImported(null, null, "Y"));
+  }
 }

Reply via email to