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 b8b863bb4a Export pipelines/workflows referenced from metadata
objects, fixes #3368 (#7541)
b8b863bb4a is described below
commit b8b863bb4ae27503aaa10f934df2cebc2ad80127
Author: Hans Van Akelyen <[email protected]>
AuthorDate: Thu Jul 16 14:46:02 2026 +0200
Export pipelines/workflows referenced from metadata objects, fixes #3368
(#7541)
---
.../org/apache/hop/core/variables/IVariables.java | 14 +
.../org/apache/hop/core/variables/Variables.java | 30 +-
.../java/org/apache/hop/resource/ResourceUtil.java | 227 ++++++++++++++-
.../hop_server/0008-trigger-pipeline.hpl | 85 ++++++
.../0008-verify-metadata-referenced-pipeline.hwf | 134 +++++++++
.../hop_server/0009-trigger-pipeline.hpl | 76 ++++++
.../hop_server/0009-verify-resolver-pipeline.hwf | 133 +++++++++
...main-0008-test-metadata-referenced-pipeline.hwf | 95 +++++++
.../main-0009-test-variable-resolver-pipeline.hwf | 98 +++++++
.../metadata/pipeline-log/0008-metadata-log.json | 17 ++
.../metadata/variable-resolver/resolver-0009.json | 13 +
.../hop_server/reflection/0008-log-marker.hpl | 151 ++++++++++
.../hop_server/reflection/0009-resolver.hpl | 174 ++++++++++++
.../export/MetadataResourceExportTest.java | 303 +++++++++++++++++++++
.../kafka/consumer/KafkaConsumerInputMeta.java | 12 +
.../kafka/consumer/KafkaConsumerInputMetaTest.java | 18 ++
16 files changed, 1574 insertions(+), 6 deletions(-)
diff --git a/core/src/main/java/org/apache/hop/core/variables/IVariables.java
b/core/src/main/java/org/apache/hop/core/variables/IVariables.java
index 969637c06e..ebe9c5ab5c 100644
--- a/core/src/main/java/org/apache/hop/core/variables/IVariables.java
+++ b/core/src/main/java/org/apache/hop/core/variables/IVariables.java
@@ -20,6 +20,7 @@ package org.apache.hop.core.variables;
import java.util.Map;
import org.apache.hop.core.exception.HopValueException;
import org.apache.hop.core.row.IRowMeta;
+import org.apache.hop.metadata.api.IHopMetadataProvider;
/** Interface to implement variable sensitive objects. */
public interface IVariables {
@@ -146,4 +147,17 @@ public interface IVariables {
* @throws HopValueException In case there is a String conversion error
*/
String resolve(String aString, IRowMeta rowMeta, Object[] rowData) throws
HopValueException;
+
+ /**
+ * The metadata provider associated with the execution this variable space
belongs to (e.g. a
+ * running pipeline or workflow). It is used, for example, to look up
variable resolvers so that
+ * they are resolved against the metadata of the current execution - the
exported/bundled metadata
+ * on a remote server - rather than only the process-global metadata.
Returns {@code null} by
+ * default; execution engines expose their own provider.
+ *
+ * @return the metadata provider of this execution, or {@code null} if none.
+ */
+ default IHopMetadataProvider getMetadataProvider() {
+ return null;
+ }
}
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 b04d37b3ce..cfda4fdfdb 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
@@ -35,8 +35,8 @@ import org.apache.hop.core.row.value.ValueMetaBase;
import org.apache.hop.core.util.StringUtil;
import org.apache.hop.core.util.Utils;
import org.apache.hop.core.variables.resolver.VariableResolver;
+import org.apache.hop.metadata.api.IHopMetadataProvider;
import org.apache.hop.metadata.api.IHopMetadataSerializer;
-import org.apache.hop.metadata.serializer.multi.MultiMetadataProvider;
import org.apache.hop.metadata.util.HopMetadataInstance;
import org.json.simple.JSONObject;
import org.json.simple.parser.JSONParser;
@@ -171,6 +171,26 @@ public class Variables implements IVariables {
return resolved;
}
+ /**
+ * Walk this variable space and its parents to find the metadata provider of
the current execution
+ * (an execution engine exposes its own provider through {@link
+ * IVariables#getMetadataProvider()}).
+ *
+ * @return the first non-null metadata provider found, or {@code null} if
none.
+ */
+ private IHopMetadataProvider findExecutionMetadataProvider() {
+ IVariables space = this;
+ int guard = 0;
+ while (space != null && guard++ < 100) {
+ IHopMetadataProvider provider = space.getMetadataProvider();
+ if (provider != null) {
+ return provider;
+ }
+ space = space.getParentVariables();
+ }
+ return null;
+ }
+
private String substituteVariableResolvers(String input) {
String resolved = input;
int startIndex = 0;
@@ -212,7 +232,13 @@ public class Variables implements IVariables {
}
try {
- MultiMetadataProvider provider =
HopMetadataInstance.getMetadataProvider();
+ // Resolve against the metadata of the current execution (e.g. the
exported/bundled
+ // metadata on a remote server) when available, otherwise the
process-global metadata.
+ //
+ IHopMetadataProvider provider = findExecutionMetadataProvider();
+ if (provider == null) {
+ provider = HopMetadataInstance.getMetadataProvider();
+ }
IHopMetadataSerializer<VariableResolver> serializer =
provider.getSerializer(VariableResolver.class);
diff --git a/engine/src/main/java/org/apache/hop/resource/ResourceUtil.java
b/engine/src/main/java/org/apache/hop/resource/ResourceUtil.java
index 4ebcf36bae..f5b7770ef3 100644
--- a/engine/src/main/java/org/apache/hop/resource/ResourceUtil.java
+++ b/engine/src/main/java/org/apache/hop/resource/ResourceUtil.java
@@ -18,9 +18,14 @@
package org.apache.hop.resource;
import java.io.IOException;
+import java.lang.reflect.Field;
import java.nio.charset.StandardCharsets;
+import java.util.Collections;
import java.util.HashMap;
+import java.util.IdentityHashMap;
+import java.util.List;
import java.util.Map;
+import java.util.Set;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import org.apache.commons.lang3.StringUtils;
@@ -34,8 +39,14 @@ import org.apache.hop.core.vfs.HopVfs;
import org.apache.hop.core.xml.IXml;
import org.apache.hop.core.xml.XmlHandler;
import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.metadata.api.HopMetadataProperty;
+import org.apache.hop.metadata.api.HopMetadataPropertyType;
+import org.apache.hop.metadata.api.IHopMetadata;
import org.apache.hop.metadata.api.IHopMetadataProvider;
+import org.apache.hop.metadata.api.IHopMetadataSerializer;
+import org.apache.hop.metadata.util.ReflectionUtil;
import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.workflow.WorkflowMeta;
public class ResourceUtil {
@@ -89,6 +100,18 @@ public class ResourceUtil {
// See if we need to rename folders and if we need to expose those as
variables in the
// execution configuration...
//
+ // Bundle the pipelines/workflows that are referenced from metadata
objects (Pipeline Log,
+ // Workflow Log, Pipeline Probe, variable resolvers, web services,
...) and rewrite those
+ // references to point at the copies inside the ZIP archive. Otherwise
those pipelines are
+ // missing on the remote server (see #3368).
+ //
+ // This is done first because bundling a referenced pipeline can add
new named-resource
+ // folders (DATA_PATH_n) which then need a value assigned below.
+ //
+ SerializableMetadataProvider exportMetadataProvider =
+ exportMetadataReferencedResources(
+ variables, definitions, namingInterface, metadataProvider);
+
// See if we need to rename named resource folders...
// We have a list of these in the naming interface
//
@@ -146,10 +169,7 @@ public class ResourceUtil {
ZipEntry jsonEntry = new ZipEntry("metadata.json");
jsonEntry.setComment("Export of the client metadata");
out.putNextEntry(jsonEntry);
- out.write(
- new SerializableMetadataProvider(metadataProvider)
- .toJson()
- .getBytes(StandardCharsets.UTF_8));
+
out.write(exportMetadataProvider.toJson().getBytes(StandardCharsets.UTF_8));
out.closeEntry();
String zipURL = fileObject.getName().toString();
@@ -243,6 +263,205 @@ public class ResourceUtil {
}
}
+ /**
+ * Bundle the pipelines and workflows that are referenced from metadata
objects (e.g. Pipeline
+ * Log, Workflow Log, Pipeline Probe, variable resolvers, web services) into
the export {@code
+ * definitions} and rewrite those references so they resolve inside the ZIP
archive on the remote
+ * server. Metadata objects are only serialized to {@code metadata.json};
unlike transforms and
+ * actions they are never walked by the normal resource export, so their
referenced pipelines went
+ * missing on the server (#3368).
+ *
+ * <p>The rewriting is done on an independent deep copy of the metadata so
the caller's live
+ * metadata objects are never modified.
+ *
+ * @return a metadata provider holding the rewritten copy, to be serialized
into the ZIP archive.
+ */
+ static SerializableMetadataProvider exportMetadataReferencedResources(
+ IVariables variables,
+ Map<String, ResourceDefinition> definitions,
+ IResourceNaming namingInterface,
+ IHopMetadataProvider metadataProvider)
+ throws HopException {
+
+ // Deep copy through a JSON round-trip so we never mutate the caller's
live metadata objects
+ // (for in-memory providers the objects would otherwise be shared by
reference).
+ //
+ SerializableMetadataProvider exportMetadataProvider =
+ new SerializableMetadataProvider(
+ new SerializableMetadataProvider(metadataProvider).toJson());
+
+ Set<Object> visited = Collections.newSetFromMap(new IdentityHashMap<>());
+
+ for (Class<IHopMetadata> metadataClass :
exportMetadataProvider.getMetadataClasses()) {
+ IHopMetadataSerializer<IHopMetadata> serializer =
+ exportMetadataProvider.getSerializer(metadataClass);
+ for (String name : serializer.listObjectNames()) {
+ IHopMetadata object = serializer.load(name);
+ if (rewriteMetadataFileReferences(
+ object, visited, variables, definitions, namingInterface,
metadataProvider)) {
+ serializer.save(object);
+ }
+ }
+ }
+ return exportMetadataProvider;
+ }
+
+ /**
+ * Recursively walk a metadata object, bundling and rewriting every {@link
+ * HopMetadataPropertyType#PIPELINE_FILE} / {@link
HopMetadataPropertyType#WORKFLOW_FILE} String
+ * reference it (or a nested metadata object / list) holds.
+ *
+ * @return true if anything was rewritten.
+ */
+ private static boolean rewriteMetadataFileReferences(
+ Object object,
+ Set<Object> visited,
+ IVariables variables,
+ Map<String, ResourceDefinition> definitions,
+ IResourceNaming namingInterface,
+ IHopMetadataProvider metadataProvider)
+ throws HopException {
+
+ if (object == null || !visited.add(object)) {
+ return false;
+ }
+ boolean changed = false;
+
+ for (Field field : ReflectionUtil.findAllFields(object.getClass())) {
+ HopMetadataProperty property =
field.getAnnotation(HopMetadataProperty.class);
+ if (property == null) {
+ continue;
+ }
+ HopMetadataPropertyType type = property.hopMetadataPropertyType();
+ boolean fileReference =
+ type == HopMetadataPropertyType.PIPELINE_FILE
+ || type == HopMetadataPropertyType.WORKFLOW_FILE
+ || type == HopMetadataPropertyType.HOP_FILE;
+
+ Object value = getMetadataFieldValue(object, field);
+ if (value == null) {
+ continue;
+ }
+
+ if (value instanceof String stringValue) {
+ if (fileReference && StringUtils.isNotEmpty(stringValue)) {
+ String rewritten =
+ exportReferencedFile(
+ stringValue, type, variables, definitions, namingInterface,
metadataProvider);
+ if (rewritten != null && !rewritten.equals(stringValue)) {
+ changed |= setMetadataFieldValue(object, field, rewritten);
+ }
+ }
+ } else if (value instanceof List<?> list) {
+ for (int i = 0; i < list.size(); i++) {
+ Object item = list.get(i);
+ if (item instanceof String stringItem) {
+ if (fileReference && StringUtils.isNotEmpty(stringItem)) {
+ String rewritten =
+ exportReferencedFile(
+ stringItem, type, variables, definitions,
namingInterface, metadataProvider);
+ if (rewritten != null && !rewritten.equals(stringItem)) {
+ @SuppressWarnings("unchecked")
+ List<Object> objectList = (List<Object>) list;
+ objectList.set(i, rewritten);
+ changed = true;
+ }
+ }
+ } else if (isRecursableMetadataObject(item)) {
+ changed |=
+ rewriteMetadataFileReferences(
+ item, visited, variables, definitions, namingInterface,
metadataProvider);
+ }
+ }
+ } else if (isRecursableMetadataObject(value)) {
+ changed |=
+ rewriteMetadataFileReferences(
+ value, visited, variables, definitions, namingInterface,
metadataProvider);
+ }
+ }
+ return changed;
+ }
+
+ private static Object getMetadataFieldValue(Object object, Field field) {
+ try {
+ return ReflectionUtil.getFieldValue(
+ object, field.getName(), field.getType() == boolean.class);
+ } catch (Exception e) {
+ // A metadata property without a conventional getter must never break
the whole export;
+ // just skip that field (its reference, if any, is left unchanged).
+ return null;
+ }
+ }
+
+ private static boolean setMetadataFieldValue(Object object, Field field,
String value) {
+ try {
+ ReflectionUtil.setFieldValue(object, field.getName(), String.class,
value);
+ return true;
+ } catch (Exception e) {
+ // No usable setter: leave the reference unchanged rather than break the
export.
+ return false;
+ }
+ }
+
+ /** Only recurse into Hop metadata objects, not JDK types, enums or
primitives. */
+ private static boolean isRecursableMetadataObject(Object value) {
+ if (value == null) {
+ return false;
+ }
+ Class<?> clazz = value.getClass();
+ return !clazz.isEnum() && !clazz.isPrimitive() &&
clazz.getName().startsWith("org.apache.hop.");
+ }
+
+ /**
+ * Load the referenced pipeline or workflow, export its resources into
{@code definitions} and
+ * return a reference that resolves against the ZIP archive on the server
({@code
+ * ${Internal.Entry.Current.Folder}/<bundled name>}). For an ambiguous {@link
+ * HopMetadataPropertyType#HOP_FILE} reference the type is decided from the
{@code .hwf}/{@code
+ * .hpl} extension (defaulting to a pipeline). A reference that cannot be
resolved to an existing
+ * file, or that fails to load/export, is left unchanged: the export must
not fail on a peripheral
+ * or stale metadata reference (the server logs and skips it).
+ */
+ private static String exportReferencedFile(
+ String filename,
+ HopMetadataPropertyType type,
+ IVariables variables,
+ Map<String, ResourceDefinition> definitions,
+ IResourceNaming namingInterface,
+ IHopMetadataProvider metadataProvider) {
+
+ String realFilename = variables.resolve(filename);
+ try {
+ if (!HopVfs.getFileObject(realFilename).exists()) {
+ return filename;
+ }
+ } catch (Exception e) {
+ // Can't tell whether it exists; leave the reference untouched rather
than break the export.
+ return filename;
+ }
+
+ boolean workflowFile =
+ type == HopMetadataPropertyType.WORKFLOW_FILE
+ || (type == HopMetadataPropertyType.HOP_FILE
+ && realFilename.toLowerCase().endsWith(".hwf"));
+
+ try {
+ String bundledName;
+ if (workflowFile) {
+ WorkflowMeta workflowMeta = new WorkflowMeta(variables, realFilename,
metadataProvider);
+ bundledName =
+ workflowMeta.exportResources(variables, definitions,
namingInterface, metadataProvider);
+ } else {
+ PipelineMeta pipelineMeta = new PipelineMeta(realFilename,
metadataProvider, variables);
+ bundledName =
+ pipelineMeta.exportResources(variables, definitions,
namingInterface, metadataProvider);
+ }
+ return "${" + Const.INTERNAL_VARIABLE_ENTRY_CURRENT_FOLDER + "}/" +
bundledName;
+ } catch (Exception e) {
+ // A peripheral metadata reference must not break the whole export;
leave it unchanged.
+ return filename;
+ }
+ }
+
public static String getExplanation(
String zipFilename, String launchFile, IResourceExport
resourceExportInterface) {
diff --git a/integration-tests/hop_server/0008-trigger-pipeline.hpl
b/integration-tests/hop_server/0008-trigger-pipeline.hpl
new file mode 100644
index 0000000000..64ff6a7fa8
--- /dev/null
+++ b/integration-tests/hop_server/0008-trigger-pipeline.hpl
@@ -0,0 +1,85 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+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.
+
+-->
+<pipeline>
+ <info>
+ <name>0008-trigger-pipeline</name>
+ <name_sync_with_filename>Y</name_sync_with_filename>
+ <description>A trivial pipeline. Its execution triggers the Pipeline Log
metadata object scoped to
+ it, which runs the referenced logging pipeline. Used to prove that a
metadata-referenced
+ pipeline is exported to the remote server (#3368).</description>
+ <extended_description/>
+ <pipeline_version/>
+ <pipeline_type>Normal</pipeline_type>
+ <parameters>
+ </parameters>
+ <capture_transform_performance>N</capture_transform_performance>
+
<transform_performance_capturing_delay>1000</transform_performance_capturing_delay>
+
<transform_performance_capturing_size_limit>100</transform_performance_capturing_size_limit>
+ <created_user>-</created_user>
+ <created_date>2024/01/01 00:00:00.000</created_date>
+ <modified_user>-</modified_user>
+ <modified_date>2024/01/01 00:00:00.000</modified_date>
+ <key_for_session_key/>
+ <is_key_private>N</is_key_private>
+ </info>
+ <notepads>
+ </notepads>
+ <order>
+ </order>
+ <transform>
+ <name>Generate rows</name>
+ <type>RowGenerator</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <fields>
+ <field>
+ <name>trigger</name>
+ <type>String</type>
+ <format/>
+ <currency/>
+ <decimal/>
+ <group/>
+ <nullif>go</nullif>
+ <length>-1</length>
+ <precision>-1</precision>
+ <set_empty_string>N</set_empty_string>
+ </field>
+ </fields>
+ <limit>1</limit>
+ <never_ending>N</never_ending>
+ <interval_in_ms>5000</interval_in_ms>
+ <row_time_field>now</row_time_field>
+ <last_time_field>FiveSecondsAgo</last_time_field>
+ <attributes/>
+ <GUI>
+ <xloc>144</xloc>
+ <yloc>96</yloc>
+ </GUI>
+ </transform>
+ <transform_error_handling>
+ </transform_error_handling>
+ <attributes/>
+</pipeline>
diff --git
a/integration-tests/hop_server/0008-verify-metadata-referenced-pipeline.hwf
b/integration-tests/hop_server/0008-verify-metadata-referenced-pipeline.hwf
new file mode 100644
index 0000000000..0d4c2d0235
--- /dev/null
+++ b/integration-tests/hop_server/0008-verify-metadata-referenced-pipeline.hwf
@@ -0,0 +1,134 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+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.
+
+-->
+<workflow>
+ <name>0008-verify-metadata-referenced-pipeline</name>
+ <name_sync_with_filename>Y</name_sync_with_filename>
+ <description>Runs entirely on the remote Hop Server (shipped there by the
"remote" run configuration
+ of the parent workflow). It runs a trigger pipeline whose start fires a
Pipeline Log metadata
+ object; that log object references a separate logging pipeline which
writes a marker file. The
+ check below - on the same server - confirms the marker exists, i.e. the
metadata-referenced
+ logging pipeline was actually bundled into the export and executed
(#3368). Without the fix the
+ logging pipeline is not exported, the server logs "... pipeline file
couldn't be found", the
+ marker is never written and this check fails.</description>
+ <extended_description/>
+ <workflow_version/>
+ <created_user>-</created_user>
+ <created_date>2024/01/01 00:00:00.000</created_date>
+ <modified_user>-</modified_user>
+ <modified_date>2024/01/01 00:00:00.000</modified_date>
+ <parameters>
+ </parameters>
+ <actions>
+ <action>
+ <name>Start</name>
+ <description/>
+ <type>SPECIAL</type>
+ <attributes/>
+ <DayOfMonth>1</DayOfMonth>
+ <hour>12</hour>
+ <intervalMinutes>60</intervalMinutes>
+ <intervalSeconds>0</intervalSeconds>
+ <minutes>0</minutes>
+ <repeat>N</repeat>
+ <schedulerType>0</schedulerType>
+ <weekDay>1</weekDay>
+ <parallel>N</parallel>
+ <xloc>50</xloc>
+ <yloc>50</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>Cleanup marker</name>
+ <description>Remove any marker left by a previous run so the check can
only succeed on a marker
+ written now.</description>
+ <type>DELETE_FILE</type>
+ <attributes/>
+ <filename>${PROJECT_HOME}/output-0008/marker.txt</filename>
+ <fail_if_file_not_exists>N</fail_if_file_not_exists>
+ <parallel>N</parallel>
+ <xloc>150</xloc>
+ <yloc>50</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>Run trigger pipeline</name>
+ <description/>
+ <type>PIPELINE</type>
+ <attributes/>
+ <filename>${PROJECT_HOME}/0008-trigger-pipeline.hpl</filename>
+ <params_from_previous>N</params_from_previous>
+ <exec_per_row>N</exec_per_row>
+ <set_logfile>N</set_logfile>
+ <logfile/>
+ <logext/>
+ <add_date>N</add_date>
+ <add_time>N</add_time>
+ <loglevel>Basic</loglevel>
+ <run_configuration>local</run_configuration>
+ <wait_until_finished>Y</wait_until_finished>
+ <create_parent_folder>N</create_parent_folder>
+ <parameters>
+ <pass_all_parameters>Y</pass_all_parameters>
+ </parameters>
+ <set_append_logfile>N</set_append_logfile>
+ <parallel>N</parallel>
+ <xloc>300</xloc>
+ <yloc>50</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>Verify logging pipeline ran</name>
+ <description/>
+ <type>FILE_EXISTS</type>
+ <attributes/>
+ <filename>${PROJECT_HOME}/output-0008/marker.txt</filename>
+ <parallel>N</parallel>
+ <xloc>470</xloc>
+ <yloc>50</yloc>
+ <attributes_hac/>
+ </action>
+ </actions>
+ <hops>
+ <hop>
+ <from>Start</from>
+ <to>Cleanup marker</to>
+ <enabled>Y</enabled>
+ <evaluation>Y</evaluation>
+ <unconditional>Y</unconditional>
+ </hop>
+ <hop>
+ <from>Cleanup marker</from>
+ <to>Run trigger pipeline</to>
+ <enabled>Y</enabled>
+ <evaluation>Y</evaluation>
+ <unconditional>Y</unconditional>
+ </hop>
+ <hop>
+ <from>Run trigger pipeline</from>
+ <to>Verify logging pipeline ran</to>
+ <enabled>Y</enabled>
+ <evaluation>Y</evaluation>
+ <unconditional>N</unconditional>
+ </hop>
+ </hops>
+ <notepads>
+ </notepads>
+ <attributes/>
+</workflow>
diff --git a/integration-tests/hop_server/0009-trigger-pipeline.hpl
b/integration-tests/hop_server/0009-trigger-pipeline.hpl
new file mode 100644
index 0000000000..df496729df
--- /dev/null
+++ b/integration-tests/hop_server/0009-trigger-pipeline.hpl
@@ -0,0 +1,76 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+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.
+
+-->
+<pipeline>
+ <info>
+ <name>0009-trigger-pipeline</name>
+ <name_sync_with_filename>Y</name_sync_with_filename>
+ <description>Resolves a variable through the resolver-0009 Variable
Resolver
+ (#{resolver-0009:...}). Resolving it runs the resolver's referenced
pipeline, which is what
+ this test proves gets exported to the remote server
(#3368).</description>
+ <extended_description/>
+ <pipeline_version/>
+ <pipeline_type>Normal</pipeline_type>
+ <parameters>
+ </parameters>
+ <capture_transform_performance>N</capture_transform_performance>
+
<transform_performance_capturing_delay>1000</transform_performance_capturing_delay>
+
<transform_performance_capturing_size_limit>100</transform_performance_capturing_size_limit>
+ <created_user>-</created_user>
+ <created_date>2024/01/01 00:00:00.000</created_date>
+ <modified_user>-</modified_user>
+ <modified_date>2024/01/01 00:00:00.000</modified_date>
+ <key_for_session_key/>
+ <is_key_private>N</is_key_private>
+ </info>
+ <notepads>
+ </notepads>
+ <order>
+ </order>
+ <transform>
+ <name>Resolve variable</name>
+ <type>GetVariable</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <fields>
+ <field>
+ <length>-1</length>
+ <name>resolved</name>
+ <precision>-1</precision>
+ <trim_type>none</trim_type>
+ <type>String</type>
+ <variable>#{resolver-0009:trigger:resolved}</variable>
+ </field>
+ </fields>
+ <attributes/>
+ <GUI>
+ <xloc>144</xloc>
+ <yloc>96</yloc>
+ </GUI>
+ </transform>
+ <transform_error_handling>
+ </transform_error_handling>
+ <attributes/>
+</pipeline>
diff --git a/integration-tests/hop_server/0009-verify-resolver-pipeline.hwf
b/integration-tests/hop_server/0009-verify-resolver-pipeline.hwf
new file mode 100644
index 0000000000..ff9ab3db00
--- /dev/null
+++ b/integration-tests/hop_server/0009-verify-resolver-pipeline.hwf
@@ -0,0 +1,133 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+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.
+
+-->
+<workflow>
+ <name>0009-verify-resolver-pipeline</name>
+ <name_sync_with_filename>Y</name_sync_with_filename>
+ <description>Runs entirely on the remote Hop Server (shipped there by the
"remote" run configuration
+ of the parent workflow). It runs a trigger pipeline that resolves
#{resolver-0009:...}; resolving
+ it runs the Variable Resolver's referenced pipeline, which writes a marker
file. The check below
+ - on the same server - confirms the marker exists, i.e. the
metadata-referenced resolver pipeline
+ was bundled into the export and executed (#3368), and the resolver was
resolved against the
+ exported metadata rather than the server's own.</description>
+ <extended_description/>
+ <workflow_version/>
+ <created_user>-</created_user>
+ <created_date>2024/01/01 00:00:00.000</created_date>
+ <modified_user>-</modified_user>
+ <modified_date>2024/01/01 00:00:00.000</modified_date>
+ <parameters>
+ </parameters>
+ <actions>
+ <action>
+ <name>Start</name>
+ <description/>
+ <type>SPECIAL</type>
+ <attributes/>
+ <DayOfMonth>1</DayOfMonth>
+ <hour>12</hour>
+ <intervalMinutes>60</intervalMinutes>
+ <intervalSeconds>0</intervalSeconds>
+ <minutes>0</minutes>
+ <repeat>N</repeat>
+ <schedulerType>0</schedulerType>
+ <weekDay>1</weekDay>
+ <parallel>N</parallel>
+ <xloc>50</xloc>
+ <yloc>50</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>Cleanup marker</name>
+ <description>Remove any marker left by a previous run so the check can
only succeed on a marker
+ written now.</description>
+ <type>DELETE_FILE</type>
+ <attributes/>
+ <filename>${PROJECT_HOME}/output-0009/resolver-marker.txt</filename>
+ <fail_if_file_not_exists>N</fail_if_file_not_exists>
+ <parallel>N</parallel>
+ <xloc>150</xloc>
+ <yloc>50</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>Run trigger pipeline</name>
+ <description/>
+ <type>PIPELINE</type>
+ <attributes/>
+ <filename>${PROJECT_HOME}/0009-trigger-pipeline.hpl</filename>
+ <params_from_previous>N</params_from_previous>
+ <exec_per_row>N</exec_per_row>
+ <set_logfile>N</set_logfile>
+ <logfile/>
+ <logext/>
+ <add_date>N</add_date>
+ <add_time>N</add_time>
+ <loglevel>Basic</loglevel>
+ <run_configuration>local</run_configuration>
+ <wait_until_finished>Y</wait_until_finished>
+ <create_parent_folder>N</create_parent_folder>
+ <parameters>
+ <pass_all_parameters>Y</pass_all_parameters>
+ </parameters>
+ <set_append_logfile>N</set_append_logfile>
+ <parallel>N</parallel>
+ <xloc>300</xloc>
+ <yloc>50</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>Verify resolver pipeline ran</name>
+ <description/>
+ <type>FILE_EXISTS</type>
+ <attributes/>
+ <filename>${PROJECT_HOME}/output-0009/resolver-marker.txt</filename>
+ <parallel>N</parallel>
+ <xloc>470</xloc>
+ <yloc>50</yloc>
+ <attributes_hac/>
+ </action>
+ </actions>
+ <hops>
+ <hop>
+ <from>Start</from>
+ <to>Cleanup marker</to>
+ <enabled>Y</enabled>
+ <evaluation>Y</evaluation>
+ <unconditional>Y</unconditional>
+ </hop>
+ <hop>
+ <from>Cleanup marker</from>
+ <to>Run trigger pipeline</to>
+ <enabled>Y</enabled>
+ <evaluation>Y</evaluation>
+ <unconditional>Y</unconditional>
+ </hop>
+ <hop>
+ <from>Run trigger pipeline</from>
+ <to>Verify resolver pipeline ran</to>
+ <enabled>Y</enabled>
+ <evaluation>Y</evaluation>
+ <unconditional>N</unconditional>
+ </hop>
+ </hops>
+ <notepads>
+ </notepads>
+ <attributes/>
+</workflow>
diff --git
a/integration-tests/hop_server/main-0008-test-metadata-referenced-pipeline.hwf
b/integration-tests/hop_server/main-0008-test-metadata-referenced-pipeline.hwf
new file mode 100644
index 0000000000..dcb587ba23
--- /dev/null
+++
b/integration-tests/hop_server/main-0008-test-metadata-referenced-pipeline.hwf
@@ -0,0 +1,95 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+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.
+
+-->
+<workflow>
+ <name>main-0008-test-metadata-referenced-pipeline</name>
+ <name_sync_with_filename>Y</name_sync_with_filename>
+ <description>Regression test for issue #3368: a pipeline referenced from a
metadata object (here a
+ Pipeline Log) must be exported to a remote Hop Server. The inner workflow
is executed remotely
+ with the "remote" run configuration (export_resources=true). On the server
a trigger pipeline
+ fires the Pipeline Log, which runs its referenced logging pipeline and
writes a marker; the
+ inner workflow then checks the marker. Before the fix the logging pipeline
was not bundled into
+ the export, so it "couldn't be found", the marker was never written and
the workflow failed.</description>
+ <extended_description/>
+ <workflow_version/>
+ <created_user>-</created_user>
+ <created_date>2024/01/01 00:00:00.000</created_date>
+ <modified_user>-</modified_user>
+ <modified_date>2024/01/01 00:00:00.000</modified_date>
+ <parameters>
+ </parameters>
+ <actions>
+ <action>
+ <name>Start</name>
+ <description/>
+ <type>SPECIAL</type>
+ <attributes/>
+ <DayOfMonth>1</DayOfMonth>
+ <hour>12</hour>
+ <intervalMinutes>60</intervalMinutes>
+ <intervalSeconds>0</intervalSeconds>
+ <minutes>0</minutes>
+ <repeat>N</repeat>
+ <schedulerType>0</schedulerType>
+ <weekDay>1</weekDay>
+ <parallel>N</parallel>
+ <xloc>50</xloc>
+ <yloc>50</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>Verify metadata-referenced pipeline on server</name>
+ <description/>
+ <type>WORKFLOW</type>
+ <attributes/>
+ <run_configuration>remote</run_configuration>
+
<filename>${PROJECT_HOME}/0008-verify-metadata-referenced-pipeline.hwf</filename>
+ <params_from_previous>N</params_from_previous>
+ <exec_per_row>N</exec_per_row>
+ <set_logfile>N</set_logfile>
+ <logfile/>
+ <logext/>
+ <add_date>N</add_date>
+ <add_time>N</add_time>
+ <loglevel>Basic</loglevel>
+ <wait_until_finished>Y</wait_until_finished>
+ <create_parent_folder>N</create_parent_folder>
+ <parameters>
+ <pass_all_parameters>Y</pass_all_parameters>
+ </parameters>
+ <set_append_logfile>N</set_append_logfile>
+ <parallel>N</parallel>
+ <xloc>224</xloc>
+ <yloc>50</yloc>
+ <attributes_hac/>
+ </action>
+ </actions>
+ <hops>
+ <hop>
+ <from>Start</from>
+ <to>Verify metadata-referenced pipeline on server</to>
+ <enabled>Y</enabled>
+ <evaluation>Y</evaluation>
+ <unconditional>Y</unconditional>
+ </hop>
+ </hops>
+ <notepads>
+ </notepads>
+ <attributes/>
+</workflow>
diff --git
a/integration-tests/hop_server/main-0009-test-variable-resolver-pipeline.hwf
b/integration-tests/hop_server/main-0009-test-variable-resolver-pipeline.hwf
new file mode 100644
index 0000000000..43f4c7a4a8
--- /dev/null
+++ b/integration-tests/hop_server/main-0009-test-variable-resolver-pipeline.hwf
@@ -0,0 +1,98 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+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.
+
+-->
+<workflow>
+ <name>main-0009-test-variable-resolver-pipeline</name>
+ <name_sync_with_filename>Y</name_sync_with_filename>
+ <description>Regression test for issue #3368 (and the
variable-resolver/exported-metadata fix): a
+ pipeline referenced from a metadata object through a nested object (a
Variable Resolver wrapping
+ a pipeline resolver) must be exported to a remote Hop Server AND the
resolver must be resolved
+ against the exported metadata. The inner workflow is executed remotely
with the "remote" run
+ configuration (export_resources=true). On the server a trigger pipeline
resolves
+ #{resolver-0009:...}, which runs the resolver's referenced pipeline and
writes a marker; the
+ inner workflow then checks the marker. Before the fix the resolver
pipeline was not bundled (and
+ the resolver was resolved against the server's own metadata), so
resolution failed, the marker
+ was never written and the workflow failed.</description>
+ <extended_description/>
+ <workflow_version/>
+ <created_user>-</created_user>
+ <created_date>2024/01/01 00:00:00.000</created_date>
+ <modified_user>-</modified_user>
+ <modified_date>2024/01/01 00:00:00.000</modified_date>
+ <parameters>
+ </parameters>
+ <actions>
+ <action>
+ <name>Start</name>
+ <description/>
+ <type>SPECIAL</type>
+ <attributes/>
+ <DayOfMonth>1</DayOfMonth>
+ <hour>12</hour>
+ <intervalMinutes>60</intervalMinutes>
+ <intervalSeconds>0</intervalSeconds>
+ <minutes>0</minutes>
+ <repeat>N</repeat>
+ <schedulerType>0</schedulerType>
+ <weekDay>1</weekDay>
+ <parallel>N</parallel>
+ <xloc>50</xloc>
+ <yloc>50</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>Verify resolver pipeline on server</name>
+ <description/>
+ <type>WORKFLOW</type>
+ <attributes/>
+ <run_configuration>remote</run_configuration>
+ <filename>${PROJECT_HOME}/0009-verify-resolver-pipeline.hwf</filename>
+ <params_from_previous>N</params_from_previous>
+ <exec_per_row>N</exec_per_row>
+ <set_logfile>N</set_logfile>
+ <logfile/>
+ <logext/>
+ <add_date>N</add_date>
+ <add_time>N</add_time>
+ <loglevel>Basic</loglevel>
+ <wait_until_finished>Y</wait_until_finished>
+ <create_parent_folder>N</create_parent_folder>
+ <parameters>
+ <pass_all_parameters>Y</pass_all_parameters>
+ </parameters>
+ <set_append_logfile>N</set_append_logfile>
+ <parallel>N</parallel>
+ <xloc>224</xloc>
+ <yloc>50</yloc>
+ <attributes_hac/>
+ </action>
+ </actions>
+ <hops>
+ <hop>
+ <from>Start</from>
+ <to>Verify resolver pipeline on server</to>
+ <enabled>Y</enabled>
+ <evaluation>Y</evaluation>
+ <unconditional>Y</unconditional>
+ </hop>
+ </hops>
+ <notepads>
+ </notepads>
+ <attributes/>
+</workflow>
diff --git
a/integration-tests/hop_server/metadata/pipeline-log/0008-metadata-log.json
b/integration-tests/hop_server/metadata/pipeline-log/0008-metadata-log.json
new file mode 100644
index 0000000000..0ff6ce736c
--- /dev/null
+++ b/integration-tests/hop_server/metadata/pipeline-log/0008-metadata-log.json
@@ -0,0 +1,17 @@
+{
+ "name": "0008-metadata-log",
+ "enabled": true,
+ "loggingParentsOnly": false,
+ "failParentOnLoggingFailure": false,
+ "pipelineFilename": "${PROJECT_HOME}/reflection/0008-log-marker.hpl",
+ "executingAtStart": true,
+ "executingPeriodically": false,
+ "intervalInSeconds": "30",
+ "executingAtEnd": false,
+ "logLevel": "BASIC",
+ "pipelinesToLog": [
+ {
+ "pipelineToLogFilename": "${PROJECT_HOME}/0008-trigger-pipeline.hpl"
+ }
+ ]
+}
diff --git
a/integration-tests/hop_server/metadata/variable-resolver/resolver-0009.json
b/integration-tests/hop_server/metadata/variable-resolver/resolver-0009.json
new file mode 100644
index 0000000000..7c45142af4
--- /dev/null
+++ b/integration-tests/hop_server/metadata/variable-resolver/resolver-0009.json
@@ -0,0 +1,13 @@
+{
+ "virtualPath": "",
+ "name": "resolver-0009",
+ "description": "Resolves a variable by running a referenced pipeline. Used
to verify that a pipeline referenced from a Variable Resolver metadata object
is exported to the remote server (#3368).",
+ "variable-resolver": {
+ "Variable-Resolver-Pipeline": {
+ "outputTransformName": "OUTPUT",
+ "filename": "${PROJECT_HOME}/reflection/0009-resolver.hpl",
+ "expressionVariableName": "VARIABLE_TO_RESOLVE",
+ "runConfigurationName": "local"
+ }
+ }
+}
diff --git a/integration-tests/hop_server/reflection/0008-log-marker.hpl
b/integration-tests/hop_server/reflection/0008-log-marker.hpl
new file mode 100644
index 0000000000..b219fff0cd
--- /dev/null
+++ b/integration-tests/hop_server/reflection/0008-log-marker.hpl
@@ -0,0 +1,151 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+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.
+
+-->
+<pipeline>
+ <info>
+ <name>0008-log-marker</name>
+ <name_sync_with_filename>Y</name_sync_with_filename>
+ <description>Logging pipeline referenced by a Pipeline Log metadata
object. When the referenced
+ pipeline is correctly bundled into the resource export (#3368) this runs
on the remote server
+ and writes a marker file, which the test then verifies.</description>
+ <extended_description/>
+ <pipeline_version/>
+ <pipeline_type>Normal</pipeline_type>
+ <parameters>
+ </parameters>
+ <capture_transform_performance>N</capture_transform_performance>
+
<transform_performance_capturing_delay>1000</transform_performance_capturing_delay>
+
<transform_performance_capturing_size_limit>100</transform_performance_capturing_size_limit>
+ <created_user>-</created_user>
+ <created_date>2024/01/01 00:00:00.000</created_date>
+ <modified_user>-</modified_user>
+ <modified_date>2024/01/01 00:00:00.000</modified_date>
+ <key_for_session_key/>
+ <is_key_private>N</is_key_private>
+ </info>
+ <notepads>
+ </notepads>
+ <order>
+ <hop>
+ <from>Generate rows</from>
+ <to>Write marker</to>
+ <enabled>Y</enabled>
+ </hop>
+ </order>
+ <transform>
+ <name>Generate rows</name>
+ <type>RowGenerator</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <fields>
+ <field>
+ <name>marker</name>
+ <type>String</type>
+ <format/>
+ <currency/>
+ <decimal/>
+ <group/>
+ <nullif>metadata-referenced-pipeline-executed</nullif>
+ <length>-1</length>
+ <precision>-1</precision>
+ <set_empty_string>N</set_empty_string>
+ </field>
+ </fields>
+ <limit>1</limit>
+ <never_ending>N</never_ending>
+ <interval_in_ms>5000</interval_in_ms>
+ <row_time_field>now</row_time_field>
+ <last_time_field>FiveSecondsAgo</last_time_field>
+ <attributes/>
+ <GUI>
+ <xloc>144</xloc>
+ <yloc>96</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>Write marker</name>
+ <type>TextFileOutput</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <separator>;</separator>
+ <enclosure>"</enclosure>
+ <enclosure_forced>N</enclosure_forced>
+ <enclosure_fix_disabled>N</enclosure_fix_disabled>
+ <header>Y</header>
+ <footer>N</footer>
+ <format>DOS</format>
+ <compression>None</compression>
+ <encoding>UTF-8</encoding>
+ <endedLine/>
+ <fileNameInField>N</fileNameInField>
+ <fileNameField/>
+ <create_parent_folder>Y</create_parent_folder>
+ <file>
+ <name>${PROJECT_HOME}/output-0008/marker</name>
+ <servlet_output>N</servlet_output>
+ <do_not_open_new_file_init>N</do_not_open_new_file_init>
+ <extention>txt</extention>
+ <append>N</append>
+ <split>N</split>
+ <haspartno>N</haspartno>
+ <add_date>N</add_date>
+ <add_time>N</add_time>
+ <SpecifyFormat>N</SpecifyFormat>
+ <date_time_format/>
+ <add_to_result_filenames>Y</add_to_result_filenames>
+ <pad>N</pad>
+ <fast_dump>N</fast_dump>
+ <splitevery/>
+ </file>
+ <fields>
+ <field>
+ <name>marker</name>
+ <type>String</type>
+ <format/>
+ <currency/>
+ <decimal/>
+ <group/>
+ <nullif/>
+ <trim_type>none</trim_type>
+ <length>-1</length>
+ <precision>-1</precision>
+ </field>
+ </fields>
+ <attributes/>
+ <GUI>
+ <xloc>384</xloc>
+ <yloc>96</yloc>
+ </GUI>
+ </transform>
+ <transform_error_handling>
+ </transform_error_handling>
+ <attributes/>
+</pipeline>
diff --git a/integration-tests/hop_server/reflection/0009-resolver.hpl
b/integration-tests/hop_server/reflection/0009-resolver.hpl
new file mode 100644
index 0000000000..a99fb49ab9
--- /dev/null
+++ b/integration-tests/hop_server/reflection/0009-resolver.hpl
@@ -0,0 +1,174 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+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.
+
+-->
+<pipeline>
+ <info>
+ <name>0009-resolver</name>
+ <name_sync_with_filename>Y</name_sync_with_filename>
+ <description>Pipeline referenced by a Variable Resolver (pipeline)
metadata object. When the
+ resolver is used (#{resolver-0009:...}) this pipeline runs to produce
the resolved value; it
+ also writes a marker file, which the test verifies to prove the
metadata-referenced resolver
+ pipeline was bundled into the export and executed on the remote server
(#3368).</description>
+ <extended_description/>
+ <pipeline_version/>
+ <pipeline_type>Normal</pipeline_type>
+ <parameters>
+ </parameters>
+ <capture_transform_performance>N</capture_transform_performance>
+
<transform_performance_capturing_delay>1000</transform_performance_capturing_delay>
+
<transform_performance_capturing_size_limit>100</transform_performance_capturing_size_limit>
+ <created_user>-</created_user>
+ <created_date>2024/01/01 00:00:00.000</created_date>
+ <modified_user>-</modified_user>
+ <modified_date>2024/01/01 00:00:00.000</modified_date>
+ <key_for_session_key/>
+ <is_key_private>N</is_key_private>
+ </info>
+ <notepads>
+ </notepads>
+ <order>
+ <hop>
+ <from>Generate rows</from>
+ <to>Write marker</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>Write marker</from>
+ <to>OUTPUT</to>
+ <enabled>Y</enabled>
+ </hop>
+ </order>
+ <transform>
+ <name>Generate rows</name>
+ <type>RowGenerator</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <fields>
+ <field>
+ <name>resolved</name>
+ <type>String</type>
+ <format/>
+ <currency/>
+ <decimal/>
+ <group/>
+ <nullif>resolved-value-3368</nullif>
+ <length>-1</length>
+ <precision>-1</precision>
+ <set_empty_string>N</set_empty_string>
+ </field>
+ </fields>
+ <limit>1</limit>
+ <never_ending>N</never_ending>
+ <interval_in_ms>5000</interval_in_ms>
+ <row_time_field>now</row_time_field>
+ <last_time_field>FiveSecondsAgo</last_time_field>
+ <attributes/>
+ <GUI>
+ <xloc>144</xloc>
+ <yloc>96</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>Write marker</name>
+ <type>TextFileOutput</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <separator>;</separator>
+ <enclosure>"</enclosure>
+ <enclosure_forced>N</enclosure_forced>
+ <enclosure_fix_disabled>N</enclosure_fix_disabled>
+ <header>Y</header>
+ <footer>N</footer>
+ <format>DOS</format>
+ <compression>None</compression>
+ <encoding>UTF-8</encoding>
+ <endedLine/>
+ <fileNameInField>N</fileNameInField>
+ <fileNameField/>
+ <create_parent_folder>Y</create_parent_folder>
+ <file>
+ <name>${PROJECT_HOME}/output-0009/resolver-marker</name>
+ <servlet_output>N</servlet_output>
+ <do_not_open_new_file_init>N</do_not_open_new_file_init>
+ <extention>txt</extention>
+ <append>N</append>
+ <split>N</split>
+ <haspartno>N</haspartno>
+ <add_date>N</add_date>
+ <add_time>N</add_time>
+ <SpecifyFormat>N</SpecifyFormat>
+ <date_time_format/>
+ <add_to_result_filenames>Y</add_to_result_filenames>
+ <pad>N</pad>
+ <fast_dump>N</fast_dump>
+ <splitevery/>
+ </file>
+ <fields>
+ <field>
+ <name>resolved</name>
+ <type>String</type>
+ <format/>
+ <currency/>
+ <decimal/>
+ <group/>
+ <nullif/>
+ <trim_type>none</trim_type>
+ <length>-1</length>
+ <precision>-1</precision>
+ </field>
+ </fields>
+ <attributes/>
+ <GUI>
+ <xloc>384</xloc>
+ <yloc>96</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>OUTPUT</name>
+ <type>Dummy</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <attributes/>
+ <GUI>
+ <xloc>592</xloc>
+ <yloc>96</yloc>
+ </GUI>
+ </transform>
+ <transform_error_handling>
+ </transform_error_handling>
+ <attributes/>
+</pipeline>
diff --git
a/plugins/misc/reflection/src/test/java/org/apache/hop/reflection/export/MetadataResourceExportTest.java
b/plugins/misc/reflection/src/test/java/org/apache/hop/reflection/export/MetadataResourceExportTest.java
new file mode 100644
index 0000000000..147b1eea4b
--- /dev/null
+++
b/plugins/misc/reflection/src/test/java/org/apache/hop/reflection/export/MetadataResourceExportTest.java
@@ -0,0 +1,303 @@
+/*
+ * 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.reflection.export;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.File;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipInputStream;
+import org.apache.hop.core.HopClientEnvironment;
+import org.apache.hop.core.plugins.PluginRegistry;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.core.variables.Variables;
+import org.apache.hop.metadata.api.HopMetadata;
+import org.apache.hop.metadata.api.HopMetadataBase;
+import org.apache.hop.metadata.api.HopMetadataProperty;
+import org.apache.hop.metadata.api.HopMetadataPropertyType;
+import org.apache.hop.metadata.api.IHopMetadata;
+import org.apache.hop.metadata.api.IHopMetadataProvider;
+import org.apache.hop.metadata.plugin.MetadataPluginType;
+import org.apache.hop.metadata.serializer.memory.MemoryMetadataProvider;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.reflection.pipeline.meta.PipelineLog;
+import org.apache.hop.reflection.pipeline.meta.PipelineToLogLocation;
+import org.apache.hop.reflection.workflow.meta.WorkflowLog;
+import org.apache.hop.resource.ResourceUtil;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * Proves that pipelines referenced from metadata objects (here a {@link
PipelineLog}) are bundled
+ * into the resource-export ZIP and that their references are rewritten to
resolve inside the
+ * archive on a remote Hop Server. See issue #3368.
+ *
+ * <p>Before the fix the referenced pipelines were missing from the ZIP and
the metadata still
+ * pointed at the original local path, so the server logged "... pipeline file
couldn't be found to
+ * execute."
+ */
+class MetadataResourceExportTest {
+
+ private static final String MINIMAL_PIPELINE =
+ "<pipeline><info><name>%s</name></info>"
+ +
"<order></order><transform_error_handling></transform_error_handling></pipeline>";
+
+ @BeforeAll
+ static void init() throws Exception {
+ HopClientEnvironment.init();
+ PluginRegistry registry = PluginRegistry.getInstance();
+ registry.registerPluginClass(
+ PipelineLog.class.getName(), MetadataPluginType.class,
HopMetadata.class);
+ registry.registerPluginClass(
+ WorkflowLog.class.getName(), MetadataPluginType.class,
HopMetadata.class);
+ registry.registerPluginClass(
+ HopFileReference.class.getName(), MetadataPluginType.class,
HopMetadata.class);
+ }
+
+ /**
+ * Minimal metadata type holding a {@link HopMetadataPropertyType#HOP_FILE}
reference, used to
+ * prove the export decides pipeline-vs-workflow from the file extension.
+ */
+ @HopMetadata(key = "test-hop-file-reference", name = "Test HOP_FILE
reference")
+ public static class HopFileReference extends HopMetadataBase implements
IHopMetadata {
+ @HopMetadataProperty(hopMetadataPropertyType =
HopMetadataPropertyType.HOP_FILE)
+ private String referencedFile;
+
+ public String getReferencedFile() {
+ return referencedFile;
+ }
+
+ public void setReferencedFile(String referencedFile) {
+ this.referencedFile = referencedFile;
+ }
+ }
+
+ private File writeWorkflow(File dir, String name) throws Exception {
+ File file = new File(dir, name + ".hwf");
+ Files.write(
+ file.toPath(),
+ ("<workflow><name>" + name +
"</name><actions></actions><hops></hops></workflow>")
+ .getBytes(StandardCharsets.UTF_8));
+ return file;
+ }
+
+ private File writePipeline(File dir, String name) throws Exception {
+ File file = new File(dir, name + ".hpl");
+ Files.write(
+ file.toPath(), String.format(MINIMAL_PIPELINE,
name).getBytes(StandardCharsets.UTF_8));
+ return file;
+ }
+
+ @Test
+ void referencedPipelinesAreBundledAndRewritten(@TempDir File tempDir) throws
Exception {
+ IVariables variables = new Variables();
+
+ // Two pipelines referenced from a single Pipeline Log metadata object:
+ // - the main logging pipeline (direct String field)
+ // - an extra logging location (String field inside a
List<PipelineToLogLocation>)
+ File logPipeline = writePipeline(tempDir, "pipeline-log");
+ File extraLogPipeline = writePipeline(tempDir, "extra-log");
+ File mainFile = writePipeline(tempDir, "main");
+
+ IHopMetadataProvider provider = new MemoryMetadataProvider();
+ PipelineLog pipelineLog = new PipelineLog("test-log");
+ pipelineLog.setEnabled(true);
+ pipelineLog.setPipelineFilename(logPipeline.getAbsolutePath());
+ pipelineLog
+ .getPipelinesToLog()
+ .add(new PipelineToLogLocation(extraLogPipeline.getAbsolutePath()));
+ provider.getSerializer(PipelineLog.class).save(pipelineLog);
+
+ PipelineMeta mainMeta = new PipelineMeta(mainFile.getAbsolutePath(),
provider, variables);
+
+ File zip = new File(tempDir, "export.zip");
+ ResourceUtil.serializeResourceExportInterface(
+ zip.getAbsolutePath(),
+ mainMeta,
+ variables,
+ provider,
+ null,
+ null,
+ null,
+ null,
+ new HashMap<>());
+
+ List<String> entries = new ArrayList<>();
+ String metadataJson = null;
+ try (ZipInputStream zis = new
ZipInputStream(Files.newInputStream(zip.toPath()))) {
+ ZipEntry e;
+ while ((e = zis.getNextEntry()) != null) {
+ entries.add(e.getName());
+ if ("metadata.json".equals(e.getName())) {
+ metadataJson = new String(zis.readAllBytes(),
StandardCharsets.UTF_8);
+ }
+ }
+ }
+
+ // The main pipeline plus BOTH referenced logging pipelines must be
bundled in the ZIP.
+ long hplCount = entries.stream().filter(name ->
name.endsWith(".hpl")).count();
+ assertEquals(3, hplCount, "Expected main + 2 referenced pipelines in the
ZIP, got: " + entries);
+
+ // The metadata references must be rewritten to resolve inside the archive
on the server,
+ // not left pointing at the original local paths.
+ assertFalse(
+ metadataJson.contains(logPipeline.getAbsolutePath()),
+ "metadata.json still contains the original pipeline path: " +
metadataJson);
+ assertFalse(
+ metadataJson.contains(extraLogPipeline.getAbsolutePath()),
+ "metadata.json still contains the original list pipeline path: " +
metadataJson);
+ // (JSON escapes '/' as '\/', so match the variable name only.)
+ assertTrue(
+ metadataJson.contains("${Internal.Entry.Current.Folder}"),
+ "metadata.json references were not rewritten to the archive folder: "
+ metadataJson);
+ }
+
+ @Test
+ void referencedWorkflowIsBundledAndRewritten(@TempDir File tempDir) throws
Exception {
+ IVariables variables = new Variables();
+
+ // A Workflow Log references a pipeline (PIPELINE_FILE String) and a
workflow
+ // (WORKFLOW_FILE inside a List<String>).
+ File logPipeline = writePipeline(tempDir, "wf-log-pipeline");
+ File loggedWorkflow = writeWorkflow(tempDir, "logged-workflow");
+ File mainFile = writePipeline(tempDir, "main");
+
+ IHopMetadataProvider provider = new MemoryMetadataProvider();
+ WorkflowLog workflowLog = new WorkflowLog("test-workflow-log");
+ workflowLog.setEnabled(true);
+ workflowLog.setPipelineFilename(logPipeline.getAbsolutePath());
+ workflowLog.getWorkflowToLog().add(loggedWorkflow.getAbsolutePath());
+ provider.getSerializer(WorkflowLog.class).save(workflowLog);
+
+ PipelineMeta mainMeta = new PipelineMeta(mainFile.getAbsolutePath(),
provider, variables);
+
+ File zip = new File(tempDir, "export.zip");
+ ResourceUtil.serializeResourceExportInterface(
+ zip.getAbsolutePath(),
+ mainMeta,
+ variables,
+ provider,
+ null,
+ null,
+ null,
+ null,
+ new HashMap<>());
+
+ List<String> entries = new ArrayList<>();
+ String metadataJson = null;
+ try (ZipInputStream zis = new
ZipInputStream(Files.newInputStream(zip.toPath()))) {
+ ZipEntry e;
+ while ((e = zis.getNextEntry()) != null) {
+ entries.add(e.getName());
+ if ("metadata.json".equals(e.getName())) {
+ metadataJson = new String(zis.readAllBytes(),
StandardCharsets.UTF_8);
+ }
+ }
+ }
+
+ // The referenced pipeline must be bundled (main + log pipeline) and the
workflow too.
+ assertEquals(
+ 2,
+ entries.stream().filter(name -> name.endsWith(".hpl")).count(),
+ "Expected main + referenced log pipeline in the ZIP, got: " + entries);
+ assertEquals(
+ 1,
+ entries.stream().filter(name -> name.endsWith(".hwf")).count(),
+ "Expected the referenced workflow to be bundled, got: " + entries);
+
+ assertFalse(
+ metadataJson.contains(logPipeline.getAbsolutePath()),
+ "metadata.json still contains the original pipeline path: " +
metadataJson);
+ assertFalse(
+ metadataJson.contains(loggedWorkflow.getAbsolutePath()),
+ "metadata.json still contains the original workflow path: " +
metadataJson);
+ assertTrue(
+ metadataJson.contains("${Internal.Entry.Current.Folder}"),
+ "metadata.json references were not rewritten to the archive folder: "
+ metadataJson);
+ }
+
+ @Test
+ void hopFileReferenceBundlesPipelineOrWorkflowByExtension(@TempDir File
tempDir)
+ throws Exception {
+ IVariables variables = new Variables();
+
+ File referencedPipeline = writePipeline(tempDir, "hopfile-pipeline");
+ File referencedWorkflow = writeWorkflow(tempDir, "hopfile-workflow");
+ File mainFile = writePipeline(tempDir, "main");
+
+ IHopMetadataProvider provider = new MemoryMetadataProvider();
+ HopFileReference pipelineRef = new HopFileReference();
+ pipelineRef.setName("hopfile-pipeline-ref");
+ pipelineRef.setReferencedFile(referencedPipeline.getAbsolutePath());
+ provider.getSerializer(HopFileReference.class).save(pipelineRef);
+ HopFileReference workflowRef = new HopFileReference();
+ workflowRef.setName("hopfile-workflow-ref");
+ workflowRef.setReferencedFile(referencedWorkflow.getAbsolutePath());
+ provider.getSerializer(HopFileReference.class).save(workflowRef);
+
+ PipelineMeta mainMeta = new PipelineMeta(mainFile.getAbsolutePath(),
provider, variables);
+
+ File zip = new File(tempDir, "export.zip");
+ ResourceUtil.serializeResourceExportInterface(
+ zip.getAbsolutePath(),
+ mainMeta,
+ variables,
+ provider,
+ null,
+ null,
+ null,
+ null,
+ new HashMap<>());
+
+ List<String> entries = new ArrayList<>();
+ String metadataJson = null;
+ try (ZipInputStream zis = new
ZipInputStream(Files.newInputStream(zip.toPath()))) {
+ ZipEntry e;
+ while ((e = zis.getNextEntry()) != null) {
+ entries.add(e.getName());
+ if ("metadata.json".equals(e.getName())) {
+ metadataJson = new String(zis.readAllBytes(),
StandardCharsets.UTF_8);
+ }
+ }
+ }
+
+ // The .hpl HOP_FILE is bundled as a pipeline (main + it), the .hwf as a
workflow.
+ assertEquals(
+ 2,
+ entries.stream().filter(name -> name.endsWith(".hpl")).count(),
+ "Expected main + referenced pipeline in the ZIP, got: " + entries);
+ assertEquals(
+ 1,
+ entries.stream().filter(name -> name.endsWith(".hwf")).count(),
+ "Expected the referenced workflow to be bundled, got: " + entries);
+ assertFalse(
+ metadataJson.contains(referencedPipeline.getAbsolutePath()),
+ "metadata.json still contains the original HOP_FILE pipeline path: " +
metadataJson);
+ assertFalse(
+ metadataJson.contains(referencedWorkflow.getAbsolutePath()),
+ "metadata.json still contains the original HOP_FILE workflow path: " +
metadataJson);
+ }
+}
diff --git
a/plugins/transforms/kafka/src/main/java/org/apache/hop/pipeline/transforms/kafka/consumer/KafkaConsumerInputMeta.java
b/plugins/transforms/kafka/src/main/java/org/apache/hop/pipeline/transforms/kafka/consumer/KafkaConsumerInputMeta.java
index 6ee54fe7e3..c596d463d6 100644
---
a/plugins/transforms/kafka/src/main/java/org/apache/hop/pipeline/transforms/kafka/consumer/KafkaConsumerInputMeta.java
+++
b/plugins/transforms/kafka/src/main/java/org/apache/hop/pipeline/transforms/kafka/consumer/KafkaConsumerInputMeta.java
@@ -454,6 +454,18 @@ public class KafkaConsumerInputMeta
return TransformWithMappingMeta.loadMappingMeta(this, metadataProvider,
variables);
}
+ /**
+ * The sub-pipeline path is kept in this transform's own {@code filename}
field (serialized as
+ * {@code pipelinePath}), which shadows the one in {@link
TransformWithMappingMeta}. Resource
+ * export rewrites the reference through this method, so it must update the
shadowing field -
+ * otherwise the exported {@code pipelinePath} keeps pointing at the
original location and the
+ * bundled sub-pipeline can't be found on a remote server (see the analysis
for #3368).
+ */
+ @Override
+ public void replaceFileName(String fileName) {
+ this.filename = fileName;
+ }
+
@Override
public boolean supportsErrorHandling() {
return true;
diff --git
a/plugins/transforms/kafka/src/test/java/org/apache/hop/pipeline/transforms/kafka/consumer/KafkaConsumerInputMetaTest.java
b/plugins/transforms/kafka/src/test/java/org/apache/hop/pipeline/transforms/kafka/consumer/KafkaConsumerInputMetaTest.java
index 4ab6a3d853..7dd6596728 100644
---
a/plugins/transforms/kafka/src/test/java/org/apache/hop/pipeline/transforms/kafka/consumer/KafkaConsumerInputMetaTest.java
+++
b/plugins/transforms/kafka/src/test/java/org/apache/hop/pipeline/transforms/kafka/consumer/KafkaConsumerInputMetaTest.java
@@ -253,4 +253,22 @@ class KafkaConsumerInputMetaTest {
assertTrue(copy.isStopWhenIdle());
assertEquals("2500", copy.getMaxIdleTimeMs());
}
+
+ /**
+ * On resource export {@code TransformWithMappingMeta.exportResources}
bundles the sub-pipeline
+ * and rewrites the reference through {@code replaceFileName(...)}. The
sub-pipeline path is
+ * stored in this transform's own {@code filename} field (serialized as
{@code pipelinePath}) and
+ * read back through {@link KafkaConsumerInputMeta#getFilename()}, so the
rewrite must land on
+ * that same field, otherwise the exported reference still points at the
original path and the
+ * sub-pipeline can't be found on a remote server.
+ */
+ @Test
+ void replaceFileNameUpdatesThePipelinePathReference() {
+ KafkaConsumerInputMeta meta = new KafkaConsumerInputMeta();
+ meta.setFilename("${PROJECT_HOME}/sub-pipeline.hpl");
+
+ meta.replaceFileName("${Internal.Entry.Current.Folder}/sub-pipeline.hpl");
+
+ assertEquals("${Internal.Entry.Current.Folder}/sub-pipeline.hpl",
meta.getFilename());
+ }
}