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 71c62b933d Issue #8012 : Resolve marketplace env file paths against
the project home (#8136)
71c62b933d is described below
commit 71c62b933d06d9091f34ea7e184f7b82ae906d9f
Author: vbhanuchander-lang <[email protected]>
AuthorDate: Thu Sep 3 09:53:09 2026 -0400
Issue #8012 : Resolve marketplace env file paths against the project home
(#8136)
The env file configured on a lifecycle environment is documented as a
reference that may contain variables, but nothing anchored it to the
project. A relative reference went straight to VFS, which resolves it
with new File(name).getAbsolutePath() against user.dir - the Hop
installation directory for a launched Hop GUI - so config/hop-env.yaml
pointed into the install and the file was never found.
The chooser had the same gap from the other side: the Browse button on
the Plugins tab passed no starting location, so it opened wherever the
file dialog defaults to rather than at the file already configured.
- HopInstallSpecFiles.resolveInProject() anchors a still-relative
reference at the project home, leaving absolute paths, Windows drives,
UNC paths and VFS URLs untouched.
- The Browse and Edit buttons start at the configured spec file when it
resolves, and at the project home otherwise. A reference the user
typed wins when it already resolves, so ${PROJECT_HOME}/hop-env.yaml
survives a round trip through the editor instead of being rewritten to
an absolute path.
- LifecycleEnvironmentDialog now puts the project home on the
AttributesContext it hands to plugin tabs, which had no way to know it.
---
.../hop/marketplace/env/HopInstallSpecFiles.java | 56 ++++++
...fecycleEnvironmentDialogTabsExtensionPoint.java | 57 +++++-
...ojectEnvironmentAfterEnabledExtensionPoint.java | 6 +-
.../HopInstallSpecFilesResolveInProjectTest.java | 196 +++++++++++++++++++++
.../environment/LifecycleEnvironmentDialog.java | 22 +++
5 files changed, 332 insertions(+), 5 deletions(-)
diff --git
a/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/env/HopInstallSpecFiles.java
b/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/env/HopInstallSpecFiles.java
index f59dce6ab6..2dd550fec7 100644
---
a/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/env/HopInstallSpecFiles.java
+++
b/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/env/HopInstallSpecFiles.java
@@ -20,6 +20,7 @@ package org.apache.hop.marketplace.env;
import java.nio.file.Path;
import java.util.List;
import java.util.Locale;
+import java.util.regex.Pattern;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.vfs2.FileObject;
import org.apache.hop.core.variables.IVariables;
@@ -34,6 +35,9 @@ public final class HopInstallSpecFiles {
public static final List<String> WELL_KNOWN_NAMES =
List.of("hop-env.yaml", "hop-env.yml", "hop-env.json",
FULL_CLIENT_FILENAME);
+ /** A VFS scheme: letter, then letters/digits/+/-/. up to a colon. */
+ private static final Pattern SCHEME =
Pattern.compile("^[A-Za-z][A-Za-z0-9+.-]*:");
+
private HopInstallSpecFiles() {}
public static String resolve(String filename, IVariables variables) {
@@ -44,6 +48,58 @@ public final class HopInstallSpecFiles {
return variables != null ? variables.resolve(trimmed) : trimmed;
}
+ /**
+ * Resolve a spec file reference for lookup, anchoring a relative reference
at the project home.
+ *
+ * <p>{@link #resolve(String, IVariables)} only expands variables. A
reference that is still
+ * relative afterwards is handed to VFS, which anchors it at {@code
user.dir} — the Hop
+ * installation directory for a launched Hop GUI — so {@code
config/hop-env.yaml} configured on an
+ * environment points into the install instead of the project (issue #8012).
Anchor it at the
+ * project home instead whenever one is known.
+ *
+ * @param filename the configured reference, may be null, blank, relative,
or contain variables
+ * @param variables used to expand variables, may be null
+ * @param projectHome the project home to anchor at; when blank the {@code
PROJECT_HOME} variable
+ * is used
+ * @return the resolved reference, unchanged when it is absolute or no
project home is known
+ */
+ public static String resolveInProject(String filename, IVariables variables,
String projectHome) {
+ String resolved = resolve(filename, variables);
+ if (StringUtils.isBlank(resolved) || !isRelative(resolved)) {
+ return resolved;
+ }
+ String home =
+ StringUtils.isNotBlank(projectHome)
+ ? resolve(projectHome, variables)
+ : (variables != null
+ ? resolve(variables.getVariable("PROJECT_HOME"), variables)
+ : null);
+ if (StringUtils.isBlank(home) || isRelative(home)) {
+ return resolved;
+ }
+ String separator = home.endsWith("/") || home.endsWith("\\") ? "" : "/";
+ return home + separator + resolved;
+ }
+
+ /**
+ * Whether a reference still needs a base to be meaningful: no VFS scheme,
no leading separator
+ * and no Windows drive letter.
+ */
+ static boolean isRelative(String filename) {
+ if (StringUtils.isBlank(filename)) {
+ return false;
+ }
+ String name = filename.trim();
+ if (name.startsWith("/") || name.startsWith("\\")) {
+ return false;
+ }
+ // A single leading letter followed by a colon is a Windows drive, not a
scheme.
+ if (name.length() >= 2 && Character.isLetter(name.charAt(0)) &&
name.charAt(1) == ':') {
+ return false;
+ }
+ return !SCHEME.matcher(name).find();
+ }
+
public static String baseName(String filename) {
if (StringUtils.isBlank(filename)) {
return "";
diff --git
a/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/xp/LifecycleEnvironmentDialogTabsExtensionPoint.java
b/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/xp/LifecycleEnvironmentDialogTabsExtensionPoint.java
index 6dec21d32b..7ee56f1ae4 100644
---
a/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/xp/LifecycleEnvironmentDialogTabsExtensionPoint.java
+++
b/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/xp/LifecycleEnvironmentDialogTabsExtensionPoint.java
@@ -18,6 +18,7 @@
package org.apache.hop.marketplace.xp;
import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.vfs2.FileObject;
import org.apache.hop.core.AttributesContext;
import org.apache.hop.core.Const;
import org.apache.hop.core.exception.HopException;
@@ -25,6 +26,7 @@ import org.apache.hop.core.extension.ExtensionPoint;
import org.apache.hop.core.extension.IExtensionPoint;
import org.apache.hop.core.logging.ILogChannel;
import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.core.vfs.HopVfs;
import org.apache.hop.i18n.BaseMessages;
import org.apache.hop.marketplace.env.HopInstallSpecFiles;
import org.apache.hop.marketplace.env.MarketplaceAttributes;
@@ -116,10 +118,7 @@ public class LifecycleEnvironmentDialogTabsExtensionPoint
wEditEnv.addListener(
SWT.Selection,
e -> {
- String initial = StringUtils.trimToNull(wEnvFile.getText());
- if (initial != null && !HopInstallSpecFiles.exists(initial,
variables)) {
- initial = null;
- }
+ String initial = existingSpecFile(extension, variables);
HopInstallSpecEditor editor = new
HopInstallSpecEditor(extension.getShell(), initial);
editor.open();
if (editor.wasSaved() &&
StringUtils.isNotBlank(editor.getCurrentFilename())) {
@@ -142,6 +141,7 @@ public class LifecycleEnvironmentDialogTabsExtensionPoint
extension.getShell(),
null,
variables,
+ browseStart(extension, variables),
new String[] {"*.yaml;*.yml;*.json", "*.*"},
new String[] {
BaseMessages.getString(PKG,
"MarketplaceDialog.EnvFile.Filter.Env"),
@@ -207,6 +207,55 @@ public class LifecycleEnvironmentDialogTabsExtensionPoint
extension.addSaveCallback(this::saveToContext);
}
+ /**
+ * The spec file this environment points at, as a reference that actually
resolves to a file.
+ *
+ * <p>The reference the user typed wins when it already resolves, so a
{@code ${PROJECT_HOME}}
+ * style reference survives a round trip through the editor. Only when it
does not resolve is it
+ * anchored at the project home — otherwise VFS anchors it at the Hop
install directory (issue
+ * #8012).
+ *
+ * @return a resolving reference, or null when nothing is configured or it
points at no file
+ */
+ private String existingSpecFile(AttributesDialogExtension extension,
IVariables variables) {
+ String configured = StringUtils.trimToNull(wEnvFile.getText());
+ if (configured == null) {
+ return null;
+ }
+ if (HopInstallSpecFiles.exists(configured, variables)) {
+ return configured;
+ }
+ String anchored =
+ StringUtils.trimToNull(
+ HopInstallSpecFiles.resolveInProject(configured, variables,
projectHome(extension)));
+ return anchored != null && HopInstallSpecFiles.exists(anchored, variables)
? anchored : null;
+ }
+
+ /**
+ * Where the file chooser should start: at the configured spec file, or at
the project home when
+ * nothing usable is configured yet. Returning null leaves the dialog to its
own default, which is
+ * the Hop install directory — the behaviour issue #8012 reports.
+ */
+ private FileObject browseStart(AttributesDialogExtension extension,
IVariables variables) {
+ try {
+ String existing = existingSpecFile(extension, variables);
+ if (existing != null) {
+ return HopVfs.getFileObject(HopInstallSpecFiles.resolve(existing,
variables), variables);
+ }
+ String home = StringUtils.trimToNull(projectHome(extension));
+ if (home != null) {
+ return HopVfs.getFileObject(HopInstallSpecFiles.resolve(home,
variables), variables);
+ }
+ } catch (Exception e) {
+ // No usable starting point: let the file dialog choose.
+ }
+ return null;
+ }
+
+ private static String projectHome(AttributesDialogExtension extension) {
+ return extension.getContext() != null ?
extension.getContext().getProjectHome() : null;
+ }
+
private void loadFromContext(AttributesContext context) {
if (wEnvFile == null || wEnvFile.isDisposed()) {
return;
diff --git
a/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/xp/ProjectEnvironmentAfterEnabledExtensionPoint.java
b/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/xp/ProjectEnvironmentAfterEnabledExtensionPoint.java
index c9d0090a0a..30a26da29c 100644
---
a/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/xp/ProjectEnvironmentAfterEnabledExtensionPoint.java
+++
b/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/xp/ProjectEnvironmentAfterEnabledExtensionPoint.java
@@ -264,7 +264,11 @@ public class ProjectEnvironmentAfterEnabledExtensionPoint
AttributesContext context, IVariables variables, Path hopHome) {
String explicit = MarketplaceAttributes.envFile(context);
if (StringUtils.isNotBlank(explicit)) {
- Path found = existingSpecPath(explicit.trim(), variables);
+ // A relative reference belongs to the project, not to the Hop install
(issue #8012).
+ Path found =
+ existingSpecPath(
+ HopInstallSpecFiles.resolveInProject(explicit, variables,
context.getProjectHome()),
+ variables);
if (found != null) {
return found;
}
diff --git
a/plugins/misc/marketplace/src/test/java/org/apache/hop/marketplace/env/HopInstallSpecFilesResolveInProjectTest.java
b/plugins/misc/marketplace/src/test/java/org/apache/hop/marketplace/env/HopInstallSpecFilesResolveInProjectTest.java
new file mode 100644
index 0000000000..cd114d6a88
--- /dev/null
+++
b/plugins/misc/marketplace/src/test/java/org/apache/hop/marketplace/env/HopInstallSpecFilesResolveInProjectTest.java
@@ -0,0 +1,196 @@
+/*
+ * 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.marketplace.env;
+
+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 java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.core.variables.Variables;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * A relative env file reference configured on a lifecycle environment must be
anchored at the
+ * project home. Anchoring it at {@code user.dir} points it into the Hop
installation directory
+ * (issue #8012).
+ */
+class HopInstallSpecFilesResolveInProjectTest {
+
+ private static IVariables vars() {
+ IVariables variables = new Variables();
+ variables.initializeFrom(null);
+ return variables;
+ }
+
+ private static IVariables variablesWithProjectHome(String home) {
+ IVariables variables = vars();
+ if (home != null) {
+ variables.setVariable("PROJECT_HOME", home);
+ }
+ return variables;
+ }
+
+ @Test
+ void relativeReferenceIsAnchoredAtTheProjectHome() {
+ assertEquals(
+ "/home/me/projects/sales/config/hop-env.yaml",
+ HopInstallSpecFiles.resolveInProject(
+ "config/hop-env.yaml", vars(), "/home/me/projects/sales"));
+ }
+
+ @Test
+ void bareFilenameIsAnchoredToo() {
+ assertEquals(
+ "/home/me/projects/sales/hop-env.yaml",
+ HopInstallSpecFiles.resolveInProject("hop-env.yaml", vars(),
"/home/me/projects/sales"));
+ }
+
+ @Test
+ void projectHomeArgumentMayItselfBeAVariable() {
+ IVariables variables = variablesWithProjectHome("/home/me/projects/sales");
+ assertEquals(
+ "/home/me/projects/sales/config/hop-env.yaml",
+ HopInstallSpecFiles.resolveInProject("config/hop-env.yaml", variables,
"${PROJECT_HOME}"));
+ }
+
+ @Test
+ void projectHomeVariableIsUsedWhenNoHomeIsPassed() {
+ IVariables variables = variablesWithProjectHome("/home/me/projects/sales");
+ assertEquals(
+ "/home/me/projects/sales/config/hop-env.yaml",
+ HopInstallSpecFiles.resolveInProject("config/hop-env.yaml", variables,
null));
+ }
+
+ @Test
+ void referenceUsingProjectHomeExplicitlyIsResolvedAndLeftAlone() {
+ IVariables variables = variablesWithProjectHome("/home/me/projects/sales");
+ assertEquals(
+ "/home/me/projects/sales/hop-env.yaml",
+ HopInstallSpecFiles.resolveInProject(
+ "${PROJECT_HOME}/hop-env.yaml", variables,
"/home/me/projects/sales"));
+ }
+
+ @Test
+ void trailingSeparatorOnTheProjectHomeIsNotDoubled() {
+ assertEquals(
+ "/home/me/projects/sales/hop-env.yaml",
+ HopInstallSpecFiles.resolveInProject("hop-env.yaml", vars(),
"/home/me/projects/sales/"));
+ }
+
+ @Test
+ void absoluteReferencesAreNeverAnchored() {
+ assertEquals(
+ "/etc/hop/hop-env.yaml",
+ HopInstallSpecFiles.resolveInProject(
+ "/etc/hop/hop-env.yaml", vars(), "/home/me/projects/sales"));
+ assertEquals(
+ "C:\\hop\\hop-env.yaml",
+ HopInstallSpecFiles.resolveInProject(
+ "C:\\hop\\hop-env.yaml", vars(), "/home/me/projects/sales"));
+ assertEquals(
+ "\\\\server\\share\\hop-env.yaml",
+ HopInstallSpecFiles.resolveInProject(
+ "\\\\server\\share\\hop-env.yaml", vars(),
"/home/me/projects/sales"));
+ }
+
+ @Test
+ void vfsReferencesAreNeverAnchored() {
+ assertEquals(
+ "s3://bucket/hop-env.yaml",
+ HopInstallSpecFiles.resolveInProject(
+ "s3://bucket/hop-env.yaml", vars(), "/home/me/projects/sales"));
+ assertEquals(
+ "file:///etc/hop/hop-env.yaml",
+ HopInstallSpecFiles.resolveInProject(
+ "file:///etc/hop/hop-env.yaml", vars(),
"/home/me/projects/sales"));
+ }
+
+ @Test
+ void withoutAProjectHomeTheReferenceIsHandedOnUnchanged() {
+ // No project home anywhere: keep the previous behaviour rather than
invent a base.
+ assertEquals(
+ "config/hop-env.yaml",
+ HopInstallSpecFiles.resolveInProject("config/hop-env.yaml", vars(),
null));
+ assertEquals(
+ "config/hop-env.yaml",
+ HopInstallSpecFiles.resolveInProject("config/hop-env.yaml", vars(), "
"));
+ }
+
+ @Test
+ void aRelativeProjectHomeIsNoBetterThanNone() {
+ assertEquals(
+ "hop-env.yaml",
+ HopInstallSpecFiles.resolveInProject("hop-env.yaml", vars(),
"projects/sales"));
+ }
+
+ @Test
+ void blankReferencesStayBlank() {
+ assertNull(HopInstallSpecFiles.resolveInProject(null, vars(), "/home/me"));
+ assertEquals("", HopInstallSpecFiles.resolveInProject(" ", vars(),
"/home/me").trim());
+ }
+
+ @Test
+ void nullVariablesAreTolerated() {
+ assertEquals(
+ "/home/me/hop-env.yaml",
+ HopInstallSpecFiles.resolveInProject("hop-env.yaml", null,
"/home/me"));
+ }
+
+ /**
+ * The defect itself: a project relative reference handed straight to VFS is
looked up under
+ * {@code user.dir} — the Hop install for a launched Hop GUI — so the file
the user configured is
+ * not found, while the same reference anchored at the project home is.
+ */
+ @Test
+ void relativeReferenceIsOnlyFoundOnceAnchoredAtTheProject(@TempDir Path
projectHome)
+ throws Exception {
+ Path config = Files.createDirectories(projectHome.resolve("config"));
+ Files.writeString(
+ config.resolve("hop-env.yaml"),
+ "version: \"1.0\"\nhopVersion: \"2.19.0\"\n",
+ StandardCharsets.UTF_8);
+ IVariables variables = vars();
+
+ assertFalse(
+ HopInstallSpecFiles.exists(
+ HopInstallSpecFiles.resolve("config/hop-env.yaml", variables),
variables),
+ "a relative reference resolves against user.dir, not the project");
+ assertTrue(
+ HopInstallSpecFiles.exists(
+ HopInstallSpecFiles.resolveInProject(
+ "config/hop-env.yaml", variables, projectHome.toString()),
+ variables));
+ }
+
+ @Test
+ void isRelativeRecognisesSchemesDrivesAndRoots() {
+ assertTrue(HopInstallSpecFiles.isRelative("hop-env.yaml"));
+ assertTrue(HopInstallSpecFiles.isRelative("config/hop-env.yaml"));
+ assertTrue(HopInstallSpecFiles.isRelative("../hop-env.yaml"));
+ assertFalse(HopInstallSpecFiles.isRelative("/hop-env.yaml"));
+ assertFalse(HopInstallSpecFiles.isRelative("D:/hop/hop-env.yaml"));
+ assertFalse(HopInstallSpecFiles.isRelative("hdfs://nn:8020/hop-env.yaml"));
+ assertFalse(HopInstallSpecFiles.isRelative(null));
+ }
+}
diff --git
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/environment/LifecycleEnvironmentDialog.java
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/environment/LifecycleEnvironmentDialog.java
index 67769b52fd..b8da81d5c5 100644
---
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/environment/LifecycleEnvironmentDialog.java
+++
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/environment/LifecycleEnvironmentDialog.java
@@ -174,6 +174,9 @@ public class LifecycleEnvironmentDialog extends Dialog {
attributesContext.setProjectName(environment.getProjectName());
attributesContext.setEnvironmentName(environment.getName());
attributesContext.setPurpose(environment.getPurpose());
+ // Tabs resolve project relative paths against this; without it they fall
back to the Hop
+ // install directory (issue #8012).
+
attributesContext.setProjectHome(projectHomeOf(environment.getProjectName()));
if (environment.getConfigurationFiles() != null) {
attributesContext.setConfigurationFiles(new
ArrayList<>(environment.getConfigurationFiles()));
}
@@ -728,6 +731,25 @@ public class LifecycleEnvironmentDialog extends Dialog {
shell.dispose();
}
+ /**
+ * The home folder of the project this environment belongs to, with
variables expanded. Empty when
+ * the project is unknown or has no home configured.
+ */
+ private String projectHomeOf(String projectName) {
+ if (StringUtils.isEmpty(projectName)) {
+ return null;
+ }
+ try {
+ ProjectsConfig config = ProjectsConfigSingleton.getConfig();
+ ProjectConfig projectConfig = config == null ? null :
config.findProjectConfig(projectName);
+ String home = projectConfig == null ? null :
projectConfig.getProjectHome();
+ return StringUtils.isEmpty(home) ? null : variables.resolve(home);
+ } catch (Exception e) {
+ // Best effort: an unreadable projects config must not stop the dialog
from opening.
+ return null;
+ }
+ }
+
private void getData() {
ProjectsConfig config = ProjectsConfigSingleton.getConfig();