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 78de8bb91e harden self referencing remote run configurations, fixes 
#4086 (#7555)
78de8bb91e is described below

commit 78de8bb91e86b0536d4e8c41091e793e919eae37
Author: Hans Van Akelyen <[email protected]>
AuthorDate: Fri Jul 17 16:00:31 2026 +0200

    harden self referencing remote run configurations, fixes #4086 (#7555)
---
 .../engines/remote/RemotePipelineEngine.java       |  47 +++++-
 .../engines/remote/RemoteWorkflowEngine.java       |  47 +++++-
 .../engines/remote/RemotePipelineEngineTest.java   | 159 +++++++++++++++++++++
 .../engines/remote/RemoteWorkflowEngineTest.java   | 102 +++++++++++++
 4 files changed, 343 insertions(+), 12 deletions(-)

diff --git 
a/engine/src/main/java/org/apache/hop/pipeline/engines/remote/RemotePipelineEngine.java
 
b/engine/src/main/java/org/apache/hop/pipeline/engines/remote/RemotePipelineEngine.java
index cfb90733bf..cf867751f5 100644
--- 
a/engine/src/main/java/org/apache/hop/pipeline/engines/remote/RemotePipelineEngine.java
+++ 
b/engine/src/main/java/org/apache/hop/pipeline/engines/remote/RemotePipelineEngine.java
@@ -195,6 +195,46 @@ public class RemotePipelineEngine extends Variables 
implements IPipelineEngine<P
     return new RemotePipelineRunConfiguration();
   }
 
+  /**
+   * A remote run configuration names the run configuration the pipeline is 
executed with on the
+   * server. That one can be a remote run configuration again, which hands the 
pipeline to yet
+   * another server. Such a chain has to end somewhere: when it leads back to 
a run configuration it
+   * already passed, every server in the chain keeps handing the pipeline to 
the next one and the
+   * pipeline is registered over and over again. See issue #4086.
+   *
+   * <p>Only the run configurations this client can see are followed. A chain 
that continues into a
+   * run configuration that only exists on the server is left to that server 
to sort out.
+   *
+   * @param runConfiguration the remote run configuration to start from
+   * @throws HopException when the chain leads back to a run configuration it 
already passed
+   */
+  private void validateRunConfigurationChain(PipelineRunConfiguration 
runConfiguration)
+      throws HopException {
+    List<String> chain = new ArrayList<>();
+    chain.add(runConfiguration.getName());
+
+    PipelineRunConfiguration current = runConfiguration;
+    while (current != null
+        && current.getEngineRunConfiguration() instanceof 
RemotePipelineRunConfiguration remote) {
+      String linkedName = resolve(remote.getRunConfigurationName());
+      if (StringUtils.isEmpty(linkedName)) {
+        // Reported for the run configuration this engine was asked to run 
with.
+        //
+        return;
+      }
+      if (chain.contains(linkedName)) {
+        chain.add(linkedName);
+        throw new HopException(
+            "The remote pipeline run configuration leads back to itself: "
+                + String.join(" -> ", chain)
+                + ". The run configuration to run the pipeline with on the 
server should not lead "
+                + "back to a remote run configuration.");
+      }
+      chain.add(linkedName);
+      current = 
metadataProvider.getSerializer(PipelineRunConfiguration.class).load(linkedName);
+    }
+  }
+
   @Override
   public void prepareExecution() throws HopException {
     try {
@@ -215,18 +255,13 @@ public class RemotePipelineEngine extends Variables 
implements IPipelineEngine<P
       if (StringUtils.isEmpty(remoteRunConfigurationName)) {
         throw new HopException("No run configuration was specified to the 
remote pipeline with");
       }
-      if 
(pipelineRunConfiguration.getName().equals(remoteRunConfigurationName)) {
-        throw new HopException(
-            "The remote pipeline run configuration refers to itself '"
-                + remoteRunConfigurationName
-                + "'");
-      }
       if (metadataProvider == null) {
         throw new HopException(
             "The remote pipeline engine didn't receive a metadata to load hop 
server '"
                 + hopServerName
                 + "'");
       }
+      validateRunConfigurationChain(pipelineRunConfiguration);
 
       // Create a new log channel when we start the action
       // It's only now that we use it
diff --git 
a/engine/src/main/java/org/apache/hop/workflow/engines/remote/RemoteWorkflowEngine.java
 
b/engine/src/main/java/org/apache/hop/workflow/engines/remote/RemoteWorkflowEngine.java
index 31ce63a55f..973a448a7d 100644
--- 
a/engine/src/main/java/org/apache/hop/workflow/engines/remote/RemoteWorkflowEngine.java
+++ 
b/engine/src/main/java/org/apache/hop/workflow/engines/remote/RemoteWorkflowEngine.java
@@ -202,6 +202,46 @@ public class RemoteWorkflowEngine extends Variables 
implements IWorkflowEngine<W
     return workflowMeta == null ? null : workflowMeta.getName();
   }
 
+  /**
+   * A remote run configuration names the run configuration the workflow is 
executed with on the
+   * server. That one can be a remote run configuration again, which hands the 
workflow to yet
+   * another server. Such a chain has to end somewhere: when it leads back to 
a run configuration it
+   * already passed, every server in the chain keeps handing the workflow to 
the next one and the
+   * workflow is registered over and over again. See issue #4086.
+   *
+   * <p>Only the run configurations this client can see are followed. A chain 
that continues into a
+   * run configuration that only exists on the server is left to that server 
to sort out.
+   *
+   * @param runConfiguration the remote run configuration to start from
+   * @throws HopException when the chain leads back to a run configuration it 
already passed
+   */
+  void validateRunConfigurationChain(WorkflowRunConfiguration runConfiguration)
+      throws HopException {
+    List<String> chain = new ArrayList<>();
+    chain.add(runConfiguration.getName());
+
+    WorkflowRunConfiguration current = runConfiguration;
+    while (current != null
+        && current.getEngineRunConfiguration() instanceof 
RemoteWorkflowRunConfiguration remote) {
+      String linkedName = resolve(remote.getRunConfigurationName());
+      if (StringUtils.isEmpty(linkedName)) {
+        // Reported for the run configuration this engine was asked to run 
with.
+        //
+        return;
+      }
+      if (chain.contains(linkedName)) {
+        chain.add(linkedName);
+        throw new HopException(
+            "The remote workflow run configuration leads back to itself: "
+                + String.join(" -> ", chain)
+                + ". The run configuration to run the workflow with on the 
server should not lead "
+                + "back to a remote run configuration.");
+      }
+      chain.add(linkedName);
+      current = 
metadataProvider.getSerializer(WorkflowRunConfiguration.class).load(linkedName);
+    }
+  }
+
   @Override
   public Result startExecution() {
     try {
@@ -249,18 +289,13 @@ public class RemoteWorkflowEngine extends Variables 
implements IWorkflowEngine<W
       if (StringUtils.isEmpty(remoteRunConfigurationName)) {
         throw new HopException("No run configuration was specified to the 
remote workflow with");
       }
-      if 
(workflowRunConfiguration.getName().equals(remoteRunConfigurationName)) {
-        throw new HopException(
-            "The remote workflow run configuration refers to itself '"
-                + remoteRunConfigurationName
-                + "'");
-      }
       if (metadataProvider == null) {
         throw new HopException(
             "The remote workflow engine didn't receive a metadata to load hop 
server '"
                 + hopServerName
                 + "'");
       }
+      validateRunConfigurationChain(workflowRunConfiguration);
 
       logChannel.logBasic(
           "Executing this workflow using the Remote Workflow Engine with run 
configuration '"
diff --git 
a/engine/src/test/java/org/apache/hop/pipeline/engines/remote/RemotePipelineEngineTest.java
 
b/engine/src/test/java/org/apache/hop/pipeline/engines/remote/RemotePipelineEngineTest.java
new file mode 100644
index 0000000000..0bc094afe2
--- /dev/null
+++ 
b/engine/src/test/java/org/apache/hop/pipeline/engines/remote/RemotePipelineEngineTest.java
@@ -0,0 +1,159 @@
+/*
+ * 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.hop.pipeline.engines.remote;
+
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.ArrayList;
+import org.apache.hop.core.HopEnvironment;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.logging.LogLevel;
+import org.apache.hop.metadata.api.IHopMetadataProvider;
+import org.apache.hop.metadata.serializer.memory.MemoryMetadataProvider;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.pipeline.config.PipelineRunConfiguration;
+import org.apache.hop.pipeline.engines.local.LocalPipelineRunConfiguration;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+/**
+ * The run configuration a remote run configuration hands the pipeline to is 
used on the server.
+ * When that leads back to a remote run configuration the pipeline keeps being 
handed on and
+ * registered again and again. See issue #4086.
+ */
+class RemotePipelineEngineTest {
+
+  private static final String SERVER_NAME = "a-server";
+
+  @BeforeAll
+  static void setUpBeforeClass() throws HopException {
+    HopEnvironment.init();
+  }
+
+  /** A remote run configuration that names itself never reaches a server that 
would run it. */
+  @Test
+  void runConfigurationThatRefersToItselfIsRejected() throws Exception {
+    MemoryMetadataProvider metadataProvider = new MemoryMetadataProvider();
+    save(metadataProvider, remote("remote", "remote"));
+
+    HopException e = assertThrows(HopException.class, () -> 
prepare(metadataProvider, "remote"));
+
+    assertTrue(e.getMessage().contains("remote -> remote"), "The chain should 
be reported: " + e);
+  }
+
+  /**
+   * The name of the run configuration to use on the server can hold a 
variable, which the check has
+   * to resolve before it can see that it leads back to itself.
+   */
+  @Test
+  void runConfigurationThatRefersToItselfThroughAVariableIsRejected() throws 
Exception {
+    MemoryMetadataProvider metadataProvider = new MemoryMetadataProvider();
+    save(metadataProvider, remote("remote", "${RUN_CONFIG}"));
+
+    RemotePipelineEngine engine = engine(metadataProvider, "remote");
+    engine.setVariable("RUN_CONFIG", "remote");
+
+    HopException e = assertThrows(HopException.class, 
engine::prepareExecution);
+
+    assertTrue(e.getMessage().contains("remote -> remote"), "The chain should 
be reported: " + e);
+  }
+
+  /** A chain of remote run configurations that leads back to an earlier one 
is rejected. */
+  @Test
+  void cyclicalRunConfigurationChainIsRejected() throws Exception {
+    MemoryMetadataProvider metadataProvider = new MemoryMetadataProvider();
+    save(metadataProvider, remote("first", "second"));
+    save(metadataProvider, remote("second", "first"));
+
+    HopException e = assertThrows(HopException.class, () -> 
prepare(metadataProvider, "first"));
+
+    assertTrue(
+        e.getMessage().contains("first -> second -> first"), "The chain should 
be reported: " + e);
+  }
+
+  /**
+   * A remote run configuration that hands the pipeline to a local one is what 
a remote run
+   * configuration is meant to do, so it may not be rejected. It fails later 
on, when it looks for
+   * the server that does not exist here.
+   */
+  @Test
+  void runConfigurationThatLeadsToALocalOneIsAccepted() throws Exception {
+    MemoryMetadataProvider metadataProvider = new MemoryMetadataProvider();
+    save(metadataProvider, remote("remote", "local"));
+    save(metadataProvider, local("local"));
+
+    HopException e = assertThrows(HopException.class, () -> 
prepare(metadataProvider, "remote"));
+
+    assertTrue(
+        e.getMessage().contains(SERVER_NAME),
+        "It should get as far as looking for the server: " + e);
+  }
+
+  /**
+   * A run configuration that only exists on the server cannot be followed 
from here, which may not
+   * be reported as a problem.
+   */
+  @Test
+  void runConfigurationThatIsUnknownHereIsAccepted() throws Exception {
+    MemoryMetadataProvider metadataProvider = new MemoryMetadataProvider();
+    save(metadataProvider, remote("remote", "only-known-on-the-server"));
+
+    HopException e = assertThrows(HopException.class, () -> 
prepare(metadataProvider, "remote"));
+
+    assertTrue(
+        e.getMessage().contains(SERVER_NAME),
+        "It should get as far as looking for the server: " + e);
+  }
+
+  private static PipelineRunConfiguration remote(String name, String 
runConfigurationName) {
+    RemotePipelineRunConfiguration engineConfiguration = new 
RemotePipelineRunConfiguration();
+    engineConfiguration.setEnginePluginId("Remote");
+    engineConfiguration.setHopServerName(SERVER_NAME);
+    engineConfiguration.setRunConfigurationName(runConfigurationName);
+    return new PipelineRunConfiguration(
+        name, "", null, new ArrayList<>(), engineConfiguration, null, false);
+  }
+
+  private static PipelineRunConfiguration local(String name) {
+    LocalPipelineRunConfiguration engineConfiguration = new 
LocalPipelineRunConfiguration();
+    engineConfiguration.setEnginePluginId("Local");
+    return new PipelineRunConfiguration(
+        name, "", null, new ArrayList<>(), engineConfiguration, null, false);
+  }
+
+  private static void save(IHopMetadataProvider metadataProvider, 
PipelineRunConfiguration c)
+      throws HopException {
+    metadataProvider.getSerializer(PipelineRunConfiguration.class).save(c);
+  }
+
+  private static void prepare(IHopMetadataProvider metadataProvider, String 
name) throws Exception {
+    engine(metadataProvider, name).prepareExecution();
+  }
+
+  private static RemotePipelineEngine engine(IHopMetadataProvider 
metadataProvider, String name)
+      throws HopException {
+    RemotePipelineEngine engine = new RemotePipelineEngine();
+    engine.setLogLevel(LogLevel.BASIC);
+    engine.setMetadataProvider(metadataProvider);
+    engine.setPipelineRunConfiguration(
+        
metadataProvider.getSerializer(PipelineRunConfiguration.class).load(name));
+    engine.setPipelineMeta(new PipelineMeta());
+    return engine;
+  }
+}
diff --git 
a/engine/src/test/java/org/apache/hop/workflow/engines/remote/RemoteWorkflowEngineTest.java
 
b/engine/src/test/java/org/apache/hop/workflow/engines/remote/RemoteWorkflowEngineTest.java
index d8d38ce701..af1f8170c3 100644
--- 
a/engine/src/test/java/org/apache/hop/workflow/engines/remote/RemoteWorkflowEngineTest.java
+++ 
b/engine/src/test/java/org/apache/hop/workflow/engines/remote/RemoteWorkflowEngineTest.java
@@ -17,8 +17,11 @@
 
 package org.apache.hop.workflow.engines.remote;
 
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyInt;
 import static org.mockito.ArgumentMatchers.eq;
@@ -26,9 +29,13 @@ import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
 
 import org.apache.hop.core.Result;
+import org.apache.hop.core.exception.HopException;
 import org.apache.hop.core.gui.WorkflowTracker;
+import org.apache.hop.metadata.serializer.memory.MemoryMetadataProvider;
 import org.apache.hop.workflow.ActionResult;
 import org.apache.hop.workflow.WorkflowMeta;
+import org.apache.hop.workflow.config.WorkflowRunConfiguration;
+import org.apache.hop.workflow.engines.local.LocalWorkflowRunConfiguration;
 import org.apache.hop.www.HopServerWorkflowStatus;
 import org.apache.hop.www.RemoteHopServer;
 import org.junit.jupiter.api.Test;
@@ -103,6 +110,101 @@ class RemoteWorkflowEngineTest {
     return engine;
   }
 
+  /**
+   * The run configuration a remote run configuration hands the workflow to is 
used on the server.
+   * When that leads back to a remote run configuration the workflow keeps 
being handed on and
+   * registered again and again. See issue #4086.
+   */
+  @Test
+  void runConfigurationThatRefersToItselfIsRejected() throws Exception {
+    MemoryMetadataProvider metadataProvider = new MemoryMetadataProvider();
+    saveRunConfiguration(metadataProvider, remoteRunConfiguration("remote", 
"remote"));
+
+    HopException e =
+        assertThrows(HopException.class, () -> validateChain(metadataProvider, 
"remote"));
+
+    assertTrue(e.getMessage().contains("remote -> remote"), "The chain should 
be reported: " + e);
+  }
+
+  /** The name of the run configuration to use on the server can hold a 
variable. */
+  @Test
+  void runConfigurationThatRefersToItselfThroughAVariableIsRejected() throws 
Exception {
+    MemoryMetadataProvider metadataProvider = new MemoryMetadataProvider();
+    saveRunConfiguration(metadataProvider, remoteRunConfiguration("remote", 
"${RUN_CONFIG}"));
+
+    RemoteWorkflowEngine engine = new RemoteWorkflowEngine();
+    engine.setMetadataProvider(metadataProvider);
+    engine.setVariable("RUN_CONFIG", "remote");
+
+    HopException e =
+        assertThrows(
+            HopException.class,
+            () ->
+                engine.validateRunConfigurationChain(
+                    
metadataProvider.getSerializer(WorkflowRunConfiguration.class).load("remote")));
+
+    assertTrue(e.getMessage().contains("remote -> remote"), "The chain should 
be reported: " + e);
+  }
+
+  /** A chain of remote run configurations that leads back to an earlier one 
is rejected. */
+  @Test
+  void cyclicalRunConfigurationChainIsRejected() throws Exception {
+    MemoryMetadataProvider metadataProvider = new MemoryMetadataProvider();
+    saveRunConfiguration(metadataProvider, remoteRunConfiguration("first", 
"second"));
+    saveRunConfiguration(metadataProvider, remoteRunConfiguration("second", 
"first"));
+
+    HopException e =
+        assertThrows(HopException.class, () -> validateChain(metadataProvider, 
"first"));
+
+    assertTrue(
+        e.getMessage().contains("first -> second -> first"), "The chain should 
be reported: " + e);
+  }
+
+  /** Handing the workflow to a local run configuration is what a remote one 
is meant to do. */
+  @Test
+  void runConfigurationThatLeadsToALocalOneIsAccepted() throws Exception {
+    MemoryMetadataProvider metadataProvider = new MemoryMetadataProvider();
+    saveRunConfiguration(metadataProvider, remoteRunConfiguration("remote", 
"local"));
+    saveRunConfiguration(
+        metadataProvider,
+        new WorkflowRunConfiguration(
+            "local", "", null, new LocalWorkflowRunConfiguration(), false));
+
+    assertDoesNotThrow(() -> validateChain(metadataProvider, "remote"));
+  }
+
+  /** A run configuration that only exists on the server cannot be followed 
from here. */
+  @Test
+  void runConfigurationThatIsUnknownHereIsAccepted() throws Exception {
+    MemoryMetadataProvider metadataProvider = new MemoryMetadataProvider();
+    saveRunConfiguration(
+        metadataProvider, remoteRunConfiguration("remote", 
"only-known-on-the-server"));
+
+    assertDoesNotThrow(() -> validateChain(metadataProvider, "remote"));
+  }
+
+  private static WorkflowRunConfiguration remoteRunConfiguration(
+      String name, String runConfigurationName) {
+    RemoteWorkflowRunConfiguration engineConfiguration = new 
RemoteWorkflowRunConfiguration();
+    engineConfiguration.setHopServerName("a-server");
+    engineConfiguration.setRunConfigurationName(runConfigurationName);
+    return new WorkflowRunConfiguration(name, "", null, engineConfiguration, 
false);
+  }
+
+  private static void saveRunConfiguration(
+      MemoryMetadataProvider metadataProvider, WorkflowRunConfiguration 
runConfiguration)
+      throws HopException {
+    
metadataProvider.getSerializer(WorkflowRunConfiguration.class).save(runConfiguration);
+  }
+
+  private static void validateChain(MemoryMetadataProvider metadataProvider, 
String name)
+      throws HopException {
+    RemoteWorkflowEngine engine = new RemoteWorkflowEngine();
+    engine.setMetadataProvider(metadataProvider);
+    engine.validateRunConfigurationChain(
+        
metadataProvider.getSerializer(WorkflowRunConfiguration.class).load(name));
+  }
+
   private WorkflowTracker<WorkflowMeta> createServerTracker() {
     WorkflowTracker<WorkflowMeta> serverTracker = new 
WorkflowTracker<>((WorkflowMeta) null);
     serverTracker.setWorkflowName(WORKFLOW_NAME);

Reply via email to