This is an automated email from the ASF dual-hosted git repository.
mattcasters 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 08448bf72b Issue #8722 : Docker options to install marketplace plugins
(#8723)
08448bf72b is described below
commit 08448bf72bd5b46c7dc5e34b3bccb69ecba0758b
Author: Matt Casters <[email protected]>
AuthorDate: Tue Oct 6 15:58:34 2026 +0200
Issue #8722 : Docker options to install marketplace plugins (#8723)
* Issue #8722 : Docker options to install marketplace plugins
* Issue #8722 : Skip repeat marketplace installs and stop putting repo
passwords on the command line.
* Issue #8722 : Install marketplace plugins from the web image at startup.
* Issue #8722 : Document the stock apache/hop container
Describe running the published image with drivers and marketplace
plugins installed at start, and the project read through a Git VFS
connection.
---
assemblies/static/src/main/resources/hop | 26 ++
assemblies/static/src/main/resources/hop-run.sh | 26 ++
assemblies/static/src/main/resources/hop-server.sh | 26 ++
docker/resources/load-and-execute.sh | 88 +++++
docker/resources/run-web.sh | 92 +++++
docker/unified.Dockerfile | 19 +-
docker/web.Dockerfile | 3 +-
docs/hop-user-manual/modules/ROOT/pages/cloud.adoc | 7 +-
.../modules/ROOT/pages/docker-container.adoc | 70 ++++
.../ROOT/pages/getting-started/hop-next-steps.adoc | 2 +-
.../modules/ROOT/pages/hop-server/deploying.adoc | 154 +++++++-
.../hop/marketplace/catalog/PluginDiscovery.java | 13 +-
.../marketplace/command/MarketplaceCommand.java | 164 +++++++-
.../marketplace/config/MarketplaceRepository.java | 32 +-
.../apache/hop/marketplace/install/HopHome.java | 56 ++-
.../hop/marketplace/install/InstallReceipt.java | 4 +
.../hop/marketplace/install/PluginInstaller.java | 100 ++++-
.../catalog/PluginDiscoveryResolveInstallTest.java | 3 +
.../command/InstallCommandParsingTest.java | 79 ++++
.../MarketplaceRepositoryBrowserTypeTest.java | 31 ++
.../hop/marketplace/install/HopHomeTest.java | 19 +
.../marketplace/install/PluginInstallerTest.java | 425 +++++++++++++++++++++
22 files changed, 1404 insertions(+), 35 deletions(-)
diff --git a/assemblies/static/src/main/resources/hop
b/assemblies/static/src/main/resources/hop
index c1d81fc8c6..fb7b48035f 100755
--- a/assemblies/static/src/main/resources/hop
+++ b/assemblies/static/src/main/resources/hop
@@ -127,6 +127,32 @@ if [ -d "plugins/engines/beam" ]; then
CLASSPATH="${CLASSPATH}:plugins/engines/beam/*"
fi
+# Shared jars installed beside an extra plugins folder
(HOP_PLUGIN_BASE_FOLDERS) are not under
+# this install's lib/core. The folder name must be "plugins"; its parent holds
lib/core.
+if [ -n "${HOP_PLUGIN_BASE_FOLDERS:-}" ]; then
+ _hop_plugin_rest="${HOP_PLUGIN_BASE_FOLDERS}"
+ while [ -n "${_hop_plugin_rest}" ]; do
+ _hop_plugin_folder="${_hop_plugin_rest%%,*}"
+ case "${_hop_plugin_rest}" in
+ *,*) _hop_plugin_rest="${_hop_plugin_rest#*,}" ;;
+ *) _hop_plugin_rest="" ;;
+ esac
+
_hop_plugin_folder="${_hop_plugin_folder#"${_hop_plugin_folder%%[![:space:]]*}"}"
+
_hop_plugin_folder="${_hop_plugin_folder%"${_hop_plugin_folder##*[![:space:]]}"}"
+ [ -z "${_hop_plugin_folder}" ] && continue
+ if [ "$(basename "${_hop_plugin_folder}")" = "plugins" ]; then
+ _hop_plugin_home="$(dirname "${_hop_plugin_folder}")"
+ if [ -d "${_hop_plugin_home}/lib/core" ]; then
+ case ":${CLASSPATH}:" in
+ *":${_hop_plugin_home}/lib/core/*:"*) ;;
+ *) CLASSPATH="${_hop_plugin_home}/lib/core/*:${CLASSPATH}" ;;
+ esac
+ fi
+ fi
+ done
+ unset _hop_plugin_rest _hop_plugin_folder _hop_plugin_home
+fi
+
# The JVM runs from the Hop installation, so user.dir is never where the user
typed the command.
# Pass the directory they were in, for subcommands which take a path from them.
"${_HOP_JAVA}" ${HOP_OPTIONS} -Dhop.origin.dir="${ORIGINDIR}"
-Djava.library.path="${LIBPATH}" -classpath "${CLASSPATH}"
org.apache.hop.hop.Hop "$@"
diff --git a/assemblies/static/src/main/resources/hop-run.sh
b/assemblies/static/src/main/resources/hop-run.sh
index c6836cbb5d..38f353a421 100755
--- a/assemblies/static/src/main/resources/hop-run.sh
+++ b/assemblies/static/src/main/resources/hop-run.sh
@@ -111,6 +111,32 @@ if [ -n "${HOP_SPARK_CLIENT_VERSION:-}" ] && [ -d
"lib/spark-clients/${HOP_SPARK
CLASSPATH="${CLASSPATH}:lib/spark-clients/${HOP_SPARK_CLIENT_VERSION}/*"
fi
+# Shared jars installed beside an extra plugins folder
(HOP_PLUGIN_BASE_FOLDERS) are not under
+# this install's lib/core. The folder name must be "plugins"; its parent holds
lib/core.
+if [ -n "${HOP_PLUGIN_BASE_FOLDERS:-}" ]; then
+ _hop_plugin_rest="${HOP_PLUGIN_BASE_FOLDERS}"
+ while [ -n "${_hop_plugin_rest}" ]; do
+ _hop_plugin_folder="${_hop_plugin_rest%%,*}"
+ case "${_hop_plugin_rest}" in
+ *,*) _hop_plugin_rest="${_hop_plugin_rest#*,}" ;;
+ *) _hop_plugin_rest="" ;;
+ esac
+
_hop_plugin_folder="${_hop_plugin_folder#"${_hop_plugin_folder%%[![:space:]]*}"}"
+
_hop_plugin_folder="${_hop_plugin_folder%"${_hop_plugin_folder##*[![:space:]]}"}"
+ [ -z "${_hop_plugin_folder}" ] && continue
+ if [ "$(basename "${_hop_plugin_folder}")" = "plugins" ]; then
+ _hop_plugin_home="$(dirname "${_hop_plugin_folder}")"
+ if [ -d "${_hop_plugin_home}/lib/core" ]; then
+ case ":${CLASSPATH}:" in
+ *":${_hop_plugin_home}/lib/core/*:"*) ;;
+ *) CLASSPATH="${_hop_plugin_home}/lib/core/*:${CLASSPATH}" ;;
+ esac
+ fi
+ fi
+ done
+ unset _hop_plugin_rest _hop_plugin_folder _hop_plugin_home
+fi
+
"${_HOP_JAVA}" ${HOP_OPTIONS} -Djava.library.path="${LIBPATH}" -classpath
"${CLASSPATH}" org.apache.hop.run.HopRun "$@"
EXITCODE=$?
diff --git a/assemblies/static/src/main/resources/hop-server.sh
b/assemblies/static/src/main/resources/hop-server.sh
index 08adcdb47d..d363b2fb43 100755
--- a/assemblies/static/src/main/resources/hop-server.sh
+++ b/assemblies/static/src/main/resources/hop-server.sh
@@ -116,6 +116,32 @@ if [ -n "${HOP_SPARK_CLIENT_VERSION:-}" ] && [ -d
"lib/spark-clients/${HOP_SPARK
CLASSPATH="${CLASSPATH}:lib/spark-clients/${HOP_SPARK_CLIENT_VERSION}/*"
fi
+# Shared jars installed beside an extra plugins folder
(HOP_PLUGIN_BASE_FOLDERS) are not under
+# this install's lib/core. The folder name must be "plugins"; its parent holds
lib/core.
+if [ -n "${HOP_PLUGIN_BASE_FOLDERS:-}" ]; then
+ _hop_plugin_rest="${HOP_PLUGIN_BASE_FOLDERS}"
+ while [ -n "${_hop_plugin_rest}" ]; do
+ _hop_plugin_folder="${_hop_plugin_rest%%,*}"
+ case "${_hop_plugin_rest}" in
+ *,*) _hop_plugin_rest="${_hop_plugin_rest#*,}" ;;
+ *) _hop_plugin_rest="" ;;
+ esac
+
_hop_plugin_folder="${_hop_plugin_folder#"${_hop_plugin_folder%%[![:space:]]*}"}"
+
_hop_plugin_folder="${_hop_plugin_folder%"${_hop_plugin_folder##*[![:space:]]}"}"
+ [ -z "${_hop_plugin_folder}" ] && continue
+ if [ "$(basename "${_hop_plugin_folder}")" = "plugins" ]; then
+ _hop_plugin_home="$(dirname "${_hop_plugin_folder}")"
+ if [ -d "${_hop_plugin_home}/lib/core" ]; then
+ case ":${CLASSPATH}:" in
+ *":${_hop_plugin_home}/lib/core/*:"*) ;;
+ *) CLASSPATH="${_hop_plugin_home}/lib/core/*:${CLASSPATH}" ;;
+ esac
+ fi
+ fi
+ done
+ unset _hop_plugin_rest _hop_plugin_folder _hop_plugin_home
+fi
+
"${_HOP_JAVA}" ${HOP_OPTIONS} -Djava.library.path="${LIBPATH}" -classpath
"${CLASSPATH}" org.apache.hop.www.HopServer "$@"
EXITCODE=$?
diff --git a/docker/resources/load-and-execute.sh
b/docker/resources/load-and-execute.sh
index b579b3e5b7..60931c0ee7 100755
--- a/docker/resources/load-and-execute.sh
+++ b/docker/resources/load-and-execute.sh
@@ -75,6 +75,91 @@ install_jdbc_drivers() {
done
}
+# Download Marketplace plugins on container start, before Hop is launched.
+# Driven by environment variables:
+# HOP_PLUGINS_DOWNLOAD comma-separated plugin coordinates, installed
in one hop process:
+# short names, artifactId, artifactId:version,
or groupId:artifactId:version
+# e.g.
"org.hopper:hopper-edw:0.10.0,org.apache.hop:hop-tech-parquet:2.20.0"
+# HOP_PLUGINS_MAVEN_REPO optional Maven/Artifactory/Nexus base URL,
tried first. Other
+# configured repositories are still used when a
plugin is not there.
+# HOP_PLUGINS_REPO_ID repository id for that URL (default:
corporate-repo). Credential
+# variables are HOP_MARKETPLACE_<ID>_USERNAME,
_PASSWORD and _TOKEN.
+# HOP_PLUGINS_REPO_USERNAME optional username, exported as
HOP_MARKETPLACE_<ID>_USERNAME
+# HOP_PLUGINS_REPO_PASSWORD optional password or token. Exported as
_PASSWORD, or _TOKEN when
+# the auth type is token. Never passed as
--password.
+# HOP_PLUGINS_REPO_AUTH_TYPE optional authentication type: auto (default),
none, basic, token
+# HOP_PLUGINS_REPO_TYPE optional browse API: auto (default), nexus,
forgejo, jfrog, or maven
+# HOP_PLUGINS_ENV_FILE optional install spec file or URL
(hop-env.yaml / hop-marketplace-repo.yaml)
+# HOP_PLUGIN_BASE_FOLDERS optional writable plugins directory. The first
one that is not the
+# image's own plugins folder receives the
install.
+install_marketplace_plugins() {
+ local env_file="${HOP_PLUGINS_ENV_FILE:-}"
+ if [ -n "${env_file}" ]; then
+ log "Applying Hop marketplace environment file: ${env_file}"
+ if ! "${DEPLOYMENT_PATH}"/hop marketplace apply -f "${env_file}"; then
+ log "Error: failed to apply marketplace environment file '${env_file}'"
+ exitWithCode 8
+ fi
+ fi
+
+ local plugins="${HOP_PLUGINS_DOWNLOAD:-}"
+ if [ -z "${plugins}" ]; then
+ return 0
+ fi
+
+ local repo_id="${HOP_PLUGINS_REPO_ID:-corporate-repo}"
+ local repo_prefix
+ repo_prefix="$(printf '%s' "${repo_id}" | tr '[:lower:]' '[:upper:]' | sed
's/[^A-Z0-9]/_/g')"
+
+ local install_args=()
+ local repo_url="${HOP_PLUGINS_MAVEN_REPO:-}"
+ if [ -n "${repo_url}" ]; then
+ install_args+=("--repo-url=${repo_url}" "--repo-id=${repo_id}")
+ fi
+
+ local repo_type="${HOP_PLUGINS_REPO_TYPE:-}"
+ if [ -n "${repo_type}" ]; then
+ install_args+=("--repo-type=${repo_type}")
+ fi
+
+ local auth_type="${HOP_PLUGINS_REPO_AUTH_TYPE:-}"
+ if [ -n "${auth_type}" ]; then
+ install_args+=("--auth-type=${auth_type}")
+ fi
+
+ # MarketplaceRepository reads these. They must not be placed on the hop
command line.
+ if [ -n "${HOP_PLUGINS_REPO_USERNAME:-}" ]; then
+ export
"HOP_MARKETPLACE_${repo_prefix}_USERNAME=${HOP_PLUGINS_REPO_USERNAME}"
+ fi
+ if [ -n "${HOP_PLUGINS_REPO_PASSWORD:-}" ]; then
+ case "${auth_type}" in
+ token | TOKEN | Token)
+ export
"HOP_MARKETPLACE_${repo_prefix}_TOKEN=${HOP_PLUGINS_REPO_PASSWORD}"
+ ;;
+ *)
+ export
"HOP_MARKETPLACE_${repo_prefix}_PASSWORD=${HOP_PLUGINS_REPO_PASSWORD}"
+ ;;
+ esac
+ fi
+
+ local specs=()
+ local spec
+ for spec in ${plugins//,/ }; do
+ spec="$(echo "${spec}" | tr -d '[:space:]')"
+ [ -z "${spec}" ] && continue
+ specs+=("${spec}")
+ done
+ if [ ${#specs[@]} -eq 0 ]; then
+ return 0
+ fi
+
+ log "Installing marketplace plugins: ${plugins}"
+ if ! "${DEPLOYMENT_PATH}"/hop marketplace install "${specs[@]}"
"${install_args[@]}"; then
+ log "Error: failed to install marketplace plugins: ${plugins}"
+ exitWithCode 8
+ fi
+}
+
# write the hop-server config to a configuration file
# to avoid the password of the server being shown in ps
#
@@ -158,6 +243,9 @@ fi
# Download requested JDBC drivers (HOP_DRIVERS_DOWNLOAD) before Hop starts.
install_jdbc_drivers
+# Download requested Marketplace plugins (HOP_PLUGINS_DOWNLOAD) before Hop
starts.
+install_marketplace_plugins
+
# Set empty defaults on the Hop command options.
#
HOP_COMMAND="${HOP_COMMAND:-}"
diff --git a/docker/resources/run-web.sh b/docker/resources/run-web.sh
index 089c0c7e49..f6fea4c941 100755
--- a/docker/resources/run-web.sh
+++ b/docker/resources/run-web.sh
@@ -75,6 +75,95 @@ install_jdbc_drivers() {
done
}
+# Download Marketplace plugins on container start, before Tomcat is launched.
+# Driven by environment variables:
+# HOP_PLUGINS_DOWNLOAD comma-separated plugin coordinates, installed
in one hop process:
+# short names, artifactId, artifactId:version,
or groupId:artifactId:version
+# e.g.
"org.hopper:hopper-edw:0.10.0,org.apache.hop:hop-tech-parquet:2.20.0"
+# HOP_PLUGINS_MAVEN_REPO optional Maven/Artifactory/Nexus base URL,
tried first. Other
+# configured repositories are still used when a
plugin is not there.
+# HOP_PLUGINS_REPO_ID repository id for that URL (default:
corporate-repo). Credential
+# variables are HOP_MARKETPLACE_<ID>_USERNAME,
_PASSWORD and _TOKEN.
+# HOP_PLUGINS_REPO_USERNAME optional username, exported as
HOP_MARKETPLACE_<ID>_USERNAME
+# HOP_PLUGINS_REPO_PASSWORD optional password or token. Exported as
_PASSWORD, or _TOKEN when
+# the auth type is token. Never passed as
--password.
+# HOP_PLUGINS_REPO_AUTH_TYPE optional authentication type: auto (default),
none, basic, token
+# HOP_PLUGINS_REPO_TYPE optional browse API: auto (default), nexus,
forgejo, jfrog, or maven
+# HOP_PLUGINS_ENV_FILE optional install spec file or URL
(hop-env.yaml / hop-marketplace-repo.yaml)
+# HOP_PLUGIN_BASE_FOLDERS optional writable plugins directory. The first
one that is not the
+# image's own plugins folder receives the
install.
+install_marketplace_plugins() {
+ local env_file="${HOP_PLUGINS_ENV_FILE:-}"
+ local plugins="${HOP_PLUGINS_DOWNLOAD:-}"
+ if [ -z "${env_file}" ] && [ -z "${plugins}" ]; then
+ return 0
+ fi
+
+ if [ -n "${env_file}" ]; then
+ log "Applying Hop marketplace environment file: ${env_file}"
+ if ! "${DEPLOYMENT_PATH}"/hop marketplace apply -f "${env_file}"; then
+ log "Error: failed to apply marketplace environment file '${env_file}'"
+ exitWithCode 8
+ fi
+ fi
+
+ if [ -z "${plugins}" ]; then
+ return 0
+ fi
+
+ local repo_id="${HOP_PLUGINS_REPO_ID:-corporate-repo}"
+ local repo_prefix
+ repo_prefix="$(printf '%s' "${repo_id}" | tr '[:lower:]' '[:upper:]' | sed
's/[^A-Z0-9]/_/g')"
+
+ local install_args=()
+ local repo_url="${HOP_PLUGINS_MAVEN_REPO:-}"
+ if [ -n "${repo_url}" ]; then
+ install_args+=("--repo-url=${repo_url}" "--repo-id=${repo_id}")
+ fi
+
+ local repo_type="${HOP_PLUGINS_REPO_TYPE:-}"
+ if [ -n "${repo_type}" ]; then
+ install_args+=("--repo-type=${repo_type}")
+ fi
+
+ local auth_type="${HOP_PLUGINS_REPO_AUTH_TYPE:-}"
+ if [ -n "${auth_type}" ]; then
+ install_args+=("--auth-type=${auth_type}")
+ fi
+
+ # MarketplaceRepository reads these. They must not be placed on the hop
command line.
+ if [ -n "${HOP_PLUGINS_REPO_USERNAME:-}" ]; then
+ export
"HOP_MARKETPLACE_${repo_prefix}_USERNAME=${HOP_PLUGINS_REPO_USERNAME}"
+ fi
+ if [ -n "${HOP_PLUGINS_REPO_PASSWORD:-}" ]; then
+ case "${auth_type}" in
+ token | TOKEN | Token)
+ export
"HOP_MARKETPLACE_${repo_prefix}_TOKEN=${HOP_PLUGINS_REPO_PASSWORD}"
+ ;;
+ *)
+ export
"HOP_MARKETPLACE_${repo_prefix}_PASSWORD=${HOP_PLUGINS_REPO_PASSWORD}"
+ ;;
+ esac
+ fi
+
+ local specs=()
+ local spec
+ for spec in ${plugins//,/ }; do
+ spec="$(echo "${spec}" | tr -d '[:space:]')"
+ [ -z "${spec}" ] && continue
+ specs+=("${spec}")
+ done
+ if [ ${#specs[@]} -eq 0 ]; then
+ return 0
+ fi
+
+ log "Installing marketplace plugins: ${plugins}"
+ if ! "${DEPLOYMENT_PATH}"/hop marketplace install "${specs[@]}"
"${install_args[@]}"; then
+ log "Error: failed to install marketplace plugins: ${plugins}"
+ exitWithCode 8
+ fi
+}
+
# Ensure HOP_AUDIT_FOLDER exists and is writable by the hop process (per-user
data under
# users/<username>/). Prefer the configured path; fall back to
/tmp/hop-web-audit under
# java.io.tmpdir when a bind-mount is not writable.
@@ -223,6 +312,9 @@ fi
# Download requested JDBC drivers (HOP_DRIVERS_DOWNLOAD) before Tomcat starts.
install_jdbc_drivers
+# Download requested Marketplace plugins (HOP_PLUGINS_DOWNLOAD) before Tomcat
starts.
+install_marketplace_plugins
+
# if we have a /config/tomcat-users.xml file, copy it to the conf folder.
if [ -f "/config/tomcat-users.xml" ]; then
log "copying users file to /usr/local/tomcat/conf/"
diff --git a/docker/unified.Dockerfile b/docker/unified.Dockerfile
index dbad5cd03d..7c4f3b3d50 100644
--- a/docker/unified.Dockerfile
+++ b/docker/unified.Dockerfile
@@ -256,7 +256,8 @@ RUN chmod +x /build/hop-web-prepared/webapps/ROOT/*.sh
# Fix hop-config.json
RUN sed -i 's/config\/projects/${HOP_CONFIG_FOLDER}\/projects/g'
/build/hop-web-prepared/webapps/ROOT/config/hop-config.json
-# Set the correct classpath for hop scripts
+# Set the correct classpath for hop scripts. hop (no suffix) is the
marketplace entrypoint.
+RUN sed -i 's&lib/core/*&../../lib/*:WEB-INF/lib/*:lib/core/*&g'
/build/hop-web-prepared/webapps/ROOT/hop
RUN sed -i 's&lib/core/*&../../lib/*:WEB-INF/lib/*:lib/core/*&g'
/build/hop-web-prepared/webapps/ROOT/hop-run.sh
RUN sed -i 's&lib/core/*&../../lib/*:WEB-INF/lib/*:lib/core/*&g'
/build/hop-web-prepared/webapps/ROOT/hop-conf.sh
RUN sed -i 's&lib/core/*&../../lib/*:WEB-INF/lib/*:lib/core/*&g'
/build/hop-web-prepared/webapps/ROOT/hop-search.sh
@@ -293,6 +294,14 @@ ENV HOP_SHARED_JDBC_FOLDERS=
ENV HOP_DRIVERS_DOWNLOAD=
ENV HOP_DRIVERS_ACCEPT_LICENSE=
ENV HOP_DRIVERS_MAVEN_REPO=
+ENV HOP_PLUGINS_DOWNLOAD=
+ENV HOP_PLUGINS_MAVEN_REPO=
+ENV HOP_PLUGINS_REPO_ID=
+ENV HOP_PLUGINS_REPO_USERNAME=
+ENV HOP_PLUGINS_REPO_PASSWORD=
+ENV HOP_PLUGINS_REPO_AUTH_TYPE=
+ENV HOP_PLUGINS_REPO_TYPE=
+ENV HOP_PLUGINS_ENV_FILE=
ENV HOP_PROJECT_NAME=
ENV HOP_PROJECT_DIRECTORY=
ENV HOP_PROJECT_FOLDER=
@@ -372,6 +381,14 @@ ENV HOP_SHARED_JDBC_FOLDERS="${CATALINA_HOME}/jdbc-drivers"
ENV HOP_DRIVERS_DOWNLOAD=
ENV HOP_DRIVERS_ACCEPT_LICENSE=
ENV HOP_DRIVERS_MAVEN_REPO=
+ENV HOP_PLUGINS_DOWNLOAD=
+ENV HOP_PLUGINS_MAVEN_REPO=
+ENV HOP_PLUGINS_REPO_ID=
+ENV HOP_PLUGINS_REPO_USERNAME=
+ENV HOP_PLUGINS_REPO_PASSWORD=
+ENV HOP_PLUGINS_REPO_AUTH_TYPE=
+ENV HOP_PLUGINS_REPO_TYPE=
+ENV HOP_PLUGINS_ENV_FILE=
ENV HOP_GUI_ZOOM_FACTOR=1.0
ENV HOP_PROJECT_FOLDER=
ENV HOP_PROJECT_CONFIG_FILE_NAME=project-config.json
diff --git a/docker/web.Dockerfile b/docker/web.Dockerfile
index 4e7697d88f..96685cf0b7 100644
--- a/docker/web.Dockerfile
+++ b/docker/web.Dockerfile
@@ -104,7 +104,8 @@ RUN sed -i
's/config\/projects/${HOP_CONFIG_FOLDER}\/projects/g' "${CATALINA_HOM
RUN mkdir -p "$CATALINA_HOME"/lib/swt/linux/x86_64
-# set the correct classpath for hop-conf and hop-run
+# set the correct classpath for hop, hop-conf and hop-run
+RUN sed -i 's&lib/core/*&../../lib/*:WEB-INF/lib/*:lib/core/*&g'
${CATALINA_HOME}/webapps/ROOT/hop
RUN sed -i 's&lib/core/*&../../lib/*:WEB-INF/lib/*:lib/core/*&g'
${CATALINA_HOME}/webapps/ROOT/hop-run.sh
RUN sed -i 's&lib/core/*&../../lib/*:WEB-INF/lib/*:lib/core/*&g'
${CATALINA_HOME}/webapps/ROOT/hop-conf.sh
RUN sed -i 's&lib/core/*&../../lib/*:WEB-INF/lib/*:lib/core/*&g'
${CATALINA_HOME}/webapps/ROOT/hop-search.sh
diff --git a/docs/hop-user-manual/modules/ROOT/pages/cloud.adoc
b/docs/hop-user-manual/modules/ROOT/pages/cloud.adoc
index d65a4d0be3..a1a9d00db5 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/cloud.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/cloud.adoc
@@ -64,7 +64,7 @@ A Hop project is a folder: `project-config.json`, a
`metadata/` folder, pipeline
See xref:projects/index.adoc[Projects and environments].
Something has to put that folder inside the runtime.
-The three patterns on xref:hop-server/deploying.adoc[Deploying Hop Server] are
not Hop Server specific — they are the three ways a project reaches *any*
runtime, server or not:
+The patterns on xref:hop-server/deploying.adoc[Deploying Hop Server] are not
Hop Server specific — they are the ways a project reaches *any* runtime, server
or not:
* *Baked into an image.* CI builds a container image that contains the
project, and the platform pulls that image.
The build is the deployment; a run is immutable and reproducible.
@@ -72,10 +72,11 @@ See xref:hop-server/deploy-project-image.adoc[Project in a
Docker image].
* *Fetched at start-up.* The image is the stock `apache/hop` image and the
project is pulled in when the container starts — from git, from object storage,
or from a mounted volume.
`HOP_CUSTOM_ENTRYPOINT_EXTENSION_SHELL_FILE_PATH` exists for exactly this: a
script that runs before Hop starts.
See xref:hop-server/deploy-git-checkout.adoc[Git checkout on the server].
+The same stock image can install JDBC drivers and marketplace plugins as it
starts, and open the pipelines and workflows through a
xref:hop-server/deploying.adoc#StandardApacheHopContainer[Git VFS connection],
with no clone script and no derived image.
* *Sent by a client.* A
xref:pipeline/pipeline-run-configurations/native-remote-pipeline-engine.adoc[remote
run configuration] with *Export linked resources to server* ships a ZIP to a
server that owns no files.
See xref:hop-server/deploy-export-resources.adoc[Remote run with export
resources].
-Baking the project into an image is the optimal choice on a container
platform, because it is the only one of the three where the running unit is
fully described by an image tag.
+Baking the project into an image is the optimal choice on a container
platform, because it is the only one of these where the running unit is fully
described by an image tag.
Fetching at start-up is attractive when many small projects share one image,
at the cost of a network dependency on every start.
Data files are a separate question from project files.
@@ -254,7 +255,7 @@ If your provider is not in these lists, the question to ask
is which standard it
== See also
* xref:docker-container.adoc[Hop in Docker] — the image, its environment
variables and its two run modes
-* xref:hop-server/deploying.adoc[Deploying Hop Server] — the three ways a
project reaches a server, compared
+* xref:hop-server/deploying.adoc[Deploying Hop Server] — the ways a project
reaches a server, compared
* xref:projects/index.adoc[Projects and environments] — what belongs in the
project and what belongs in the environment
* xref:metadata-types/execution-information-location.adoc[Execution
Information Location] — where run history is kept
* xref:how-to-guides/scheduling-workflows-and-pipelines.adoc[Scheduling
workflows and pipelines] — cron, systemd, Jenkins, Kubernetes CronJob, Airflow
diff --git a/docs/hop-user-manual/modules/ROOT/pages/docker-container.adoc
b/docs/hop-user-manual/modules/ROOT/pages/docker-container.adoc
index 25ca98a996..4e45eb12ed 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/docker-container.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/docker-container.adoc
@@ -448,6 +448,76 @@ The download is supported by the client/server image
(running pipelines, workflo
NOTE: `HOP_DRIVERS_DOWNLOAD` is available from Hop version 2.19.
+[[DownloadingMarketplacePlugins]]
+== Downloading Marketplace plugins
+
+The Docker container can install optional or third-party plugins from the Hop
marketplace or a corporate Maven repository (e.g. Sonatype Nexus, JFrog
Artifactory) on startup *before* Hop starts. The stock image does this at
runtime, so a custom image is not required. A repeated start skips a plugin
when its receipt version matches and its files are still present. Every
coordinate is installed by one `hop marketplace install` invocation.
+
+Set `HOP_PLUGINS_DOWNLOAD` to a comma-separated list of plugin coordinates.
Each entry can be:
+
+* An artifact id or short name (e.g. `hop-tech-parquet`, `datavault`), when a
browsable repository or the Apache catalog can resolve it.
+* An artifact id with version (e.g. `hopper-edw:0.10.0`), with the same
limitation.
+* Full Maven coordinates (e.g. `org.hopper:hopper-edw:0.10.0`).
+
+`HOP_PLUGINS_MAVEN_REPO` is the repository to try first. The configured Apache
and Maven Central repositories are still used when a plugin is not in that
repository, so a private plugin and an Apache optional plugin can be requested
together. Pass a full `groupId:artifactId:version` for the private one when
that repository cannot be browsed.
+
+A repository that cannot be browsed (a plain Maven repository, or
`HOP_PLUGINS_REPO_TYPE=maven`) does not accept a short name. The install fails
and asks for `groupId:artifactId:version` instead of guessing `org.apache.hop`.
`HOP_PLUGINS_REPO_TYPE` selects the browse API: `auto` (default), `nexus`,
`forgejo`, `jfrog`, or `maven`. `auto` infers Nexus, Artifactory, or Forgejo
from the URL.
+
+`HOP_PLUGINS_REPO_ID` is the id of that repository (default `corporate-repo`).
The entrypoint does not put the password on the `hop` command line. It exports
`HOP_MARKETPLACE_<ID>_USERNAME` and either `HOP_MARKETPLACE_<ID>_PASSWORD` or,
when the auth type is `token`, `HOP_MARKETPLACE_<ID>_TOKEN`. For the default id
those names are `HOP_MARKETPLACE_CORPORATE_REPO_USERNAME`,
`HOP_MARKETPLACE_CORPORATE_REPO_PASSWORD` and
`HOP_MARKETPLACE_CORPORATE_REPO_TOKEN`. Any other id is uppercased, an [...]
+
+* `HOP_PLUGINS_REPO_USERNAME`: Basic auth username.
+* `HOP_PLUGINS_REPO_PASSWORD`: Basic auth password or bearer token.
+* `HOP_PLUGINS_REPO_AUTH_TYPE`: `auto` (default), `none`, `basic`, or `token`.
+
+The global `HOP_MARKETPLACE_USERNAME` and `HOP_MARKETPLACE_PASSWORD` variables
are also picked up.
+
+.Install a private plugin and an Apache plugin, keeping them on a volume
+[source,shell]
+----
+docker run -it --rm \
+ --env HOP_PLUGINS_DOWNLOAD="org.hopper:hopper-edw:0.10.0,hop-tech-parquet" \
+ --env
HOP_PLUGINS_MAVEN_REPO="https://repository.data-hopper.com/repository/hop-community-plugins/"
\
+ --env HOP_PLUGINS_REPO_ID=data-hopper \
+ --env HOP_PLUGIN_BASE_FOLDERS=/opt/hop/plugins,/files/plugins \
+ --env HOP_PROJECT_FOLDER=/project \
+ --env HOP_PROJECT_NAME=my-project \
+ --env HOP_FILE_PATH='${PROJECT_HOME}/main.hpl' \
+ --env HOP_RUN_CONFIG=local \
+ --mount type=bind,source=/path/to/project,target=/project \
+ --mount type=volume,source=hop-plugins,target=/files/plugins \
+ apache/hop
+----
+
+`HOP_PLUGIN_BASE_FOLDERS` replaces the default `plugins` folder. Include the
image plugins path and the mounted path, as in the example. The install is
written to the first writable directory in that list that is not the image
install itself. Shared `lib/core` jars are written next to that plugins folder
and added to the classpath of `hop`, `hop-run` and `hop-server`.
+
+Plugins written only into the container filesystem are not kept when the
container is removed. They are kept when that install directory is a mounted
volume, or when the install happened while building an image.
+
+The Hop web image installs marketplace plugins at startup as well. The image
rewrites the `hop` classpath the same way as `hop-run.sh`, so it includes
`WEB-INF/lib` (where the Hop jars live) as well as `lib/core`. Shared jars from
a plugin are installed into `WEB-INF/lib` so Tomcat and `hop` both load them.
Plugin folders still follow `HOP_PLUGIN_BASE_FOLDERS`.
+
+Building a derived image is optional. It avoids the download on every start
when a custom image is acceptable:
+
+[source,dockerfile]
+----
+FROM apache/hop:2.20.0
+RUN /opt/hop/hop marketplace install \
+ org.hopper:hopper-edw:0.10.0 \
+ org.apache.hop:hop-tech-parquet:2.20.0 \
+ --repo-url
https://repository.data-hopper.com/repository/hop-community-plugins/ \
+ --repo-id data-hopper
+----
+
+If you maintain a declarative Hop environment install spec (`hop-env.yaml`) or
repository definition (`hop-marketplace-repo.yaml`), you can point to it
directly using `HOP_PLUGINS_ENV_FILE`:
+
+[source,shell]
+----
+docker run -it --rm \
+ --env
HOP_PLUGINS_ENV_FILE=https://raw.githubusercontent.com/ProjectDataHopper/hopper-edw/main/hop-marketplace-repo.yaml
\
+ ... \
+ apache/hop
+----
+
+NOTE: `HOP_PLUGINS_DOWNLOAD` is available from Hop version 2.20.
+
== Custom Entrypoint Extension Shell Script
To make the Hop Docker image even more flexible, we added a
```HOP_CUSTOM_ENTRYPOINT_EXTENSION_SHELL_FILE_PATH``` variable that accepts a
path to a custom shell script (that you provide).This shell script will run
when you start the container before your Hop project is registered with the
container's Hop config and before your Hop workflow or pipeline gets kicked off.
diff --git
a/docs/hop-user-manual/modules/ROOT/pages/getting-started/hop-next-steps.adoc
b/docs/hop-user-manual/modules/ROOT/pages/getting-started/hop-next-steps.adoc
index 6bb033009f..86b1bd3489 100644
---
a/docs/hop-user-manual/modules/ROOT/pages/getting-started/hop-next-steps.adoc
+++
b/docs/hop-user-manual/modules/ROOT/pages/getting-started/hop-next-steps.adoc
@@ -50,7 +50,7 @@ Where you go next depends on what you're trying to do.
== Run it somewhere else
* xref:projects/index.adoc[Projects] - the full reference for projects and
environments
-* xref:hop-server/deploying.adoc[Deploying Hop Server] - three ways to get a
project onto a running server
+* xref:hop-server/deploying.adoc[Deploying Hop Server] - how a project gets
onto a running server, including a stock `apache/hop` container
* xref:docker-container.adoc[Hop in Docker] - the `apache/hop` image
*
xref:pipeline/pipeline-run-configurations/pipeline-run-configurations.adoc[Pipeline
run configurations] - run the same pipeline on Apache Spark, Apache Flink or
Google Dataflow without changing it
* xref:logging/logging-basics.adoc[Logging] - log levels and where to send the
output
diff --git a/docs/hop-user-manual/modules/ROOT/pages/hop-server/deploying.adoc
b/docs/hop-user-manual/modules/ROOT/pages/hop-server/deploying.adoc
index cc8751be03..a0299da637 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/hop-server/deploying.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/hop-server/deploying.adoc
@@ -16,7 +16,7 @@ under the License.
////
[[DeployingHopServer]]
:imagesdir: ../../assets/images
-:description: How to get a Hop project onto a Hop Server: bake it into a
Docker image, send it from a client with export resources, or check it out with
git on the host.
+:description: How to get a Hop project onto a Hop Server: bake it into a
Docker image, send it from a client with export resources, check it out with
git on the host, or run the stock apache/hop image and read the project through
a Git VFS connection.
= Deploying Hop Server
@@ -26,15 +26,17 @@ See
xref:how-to-guides/scheduling-workflows-and-pipelines.adoc[Scheduling workfl
This page is the deployment story: after you develop a project, how does a
running Hop Server get the files and the configuration it needs?
-The same three patterns are how a project reaches any runtime, not only a
server.
+The same four patterns are how a project reaches any runtime, not only a
server.
For the wider picture -- containers, schedulers, secrets, object storage and
run history on a cloud platform -- see xref:cloud.adoc[Running Apache Hop in
the cloud].
-There are three patterns that cover almost every production setup:
+There are four patterns that cover almost every production setup:
* xref:hop-server/deploy-project-image.adoc[Project in a Docker image] -- CI
builds an image that contains the project.
You can roll that image as a long-lived Hop Server, but you often do not need
to: a scheduler can start a *short-lived* copy, run one pipeline or workflow,
and exit.
* xref:hop-server/deploy-export-resources.adoc[Remote run with export
resources] -- the client owns the project and sends a ZIP to a "dumb" server.
* xref:hop-server/deploy-git-checkout.adoc[Git checkout on the server] -- the
host has a clone; updating is `git pull`.
+* <<StandardApacheHopContainer,Standard apache/hop container>> -- the
published image, unchanged.
+JDBC drivers and marketplace plugins are installed when the container starts,
and the pipelines and workflows are read from git through a named VFS
connection.
== What you deploy
@@ -53,6 +55,7 @@ The official Docker image does this from `HOP_PROJECT_*` /
`HOP_ENVIRONMENT_*` v
On a host you register the project and environment once with
xref:hop-tools/hop-conf/hop-conf.adoc[hop-conf], then start the server with
`-e`.
The environment already references its project, so you do not pass `-j` as
well.
This is how xref:hop-server/deploy-project-image.adoc[the image] and
xref:hop-server/deploy-git-checkout.adoc[the git checkout] work.
+The <<StandardApacheHopContainer,standard container>> is the same shape with a
smaller folder: the enabled project holds the Git connection and the metadata
the run needs, and `HOP_FILE_PATH` points at a file inside the repository.
Environment configuration files must be present *on the server*.
* *As a payload from the client*.
@@ -66,46 +69,184 @@ When both a project and `<metadata_folder>` are set, both
providers sit on the m
== Choose a pattern
-[cols="1,2,2,2",options="header"]
+[cols="1,2,2,2,2",options="header"]
|===
-| |Project in a Docker image |Export resources from a client |Git checkout on
the server
+| |Project in a Docker image |Export resources from a client |Git checkout on
the server |Standard `apache/hop` container
|Who owns the project files
|The image (immutable per build)
|The client (Airflow, hop-run, Hop Gui)
|The server host (working tree)
+|The git remote.
+The container holds a small project (the connection and the metadata) and
checks the repository out when a file is opened
|How you update
|Rebuild and roll the container
|Change the client project; the next run sends a new ZIP
|`git pull` (restart only for environment or server config)
+|Change the revision on the connection.
+A moving branch is fetched again when the connection says so
|Environment configuration
|Mounted or injected on the *server*
|Applied on the *client*
|Files on the *server*, usually outside the clone
+|Mounted or injected on the *server*
|Extra files (JDBC, plugins, `.properties`, JSON schemas)
|`COPY` into the image or a volume
|*Not sent.* Must already exist on the server, or be rewritten with the
named-resource folder mapping
|Present in the clone or installed on the host
+|Downloaded at start, or mounted.
+See <<StandardApacheHopContainer>>
|File paths
|`+${PROJECT_HOME}+` is real on the server
|Paths are rewritten; see the
xref:hop-server/deploy-export-resources.adoc[export resources caveats]
|`+${PROJECT_HOME}+` is the clone directory
+|The file you run is a Git VFS URL such as `ops:///workflows/daily.hwf`.
+`+${PROJECT_HOME}+` is the small local project, not the repository
|Best for
|Enterprise CI/CD, Kubernetes, OpenShift; the same image as a short-lived job
*or* a long-lived server
|Orchestrators that already have the project; a server that should not own
files
|Smaller or ops-friendly hosts with git access
+|A platform where building and rolling a custom image is the expensive step
|Poor fit
-|You cannot rebuild images
+|You cannot rebuild images.
+Use <<StandardApacheHopContainer>> instead
|Heavy file I/O, extra config files, large data files
|You cannot (or must not) run `git pull` on production hosts
+|Projects that open their files through `+${PROJECT_HOME}+`, or metadata that
has to be read from inside the repository
|===
+[[StandardApacheHopContainer]]
+== Standard apache/hop container
+
+The published `apache/hop` image can take the place of a derived image.
+A Jenkins, GitHub Actions or GitLab CI job usually builds that derived image
to add JDBC drivers, install a few marketplace plugins, and clone the project.
+Here the entrypoint installs the drivers and the plugins before Hop starts,
and a xref:metadata-types/git-vfs-connection.adoc[Git repository] connection
checks the project out when Hop opens a file.
+
+The checkout is read-only by default.
+A write changes the working copy on disk, and nothing is committed or pushed.
+
+This needs Hop 2.20, which is where `HOP_PLUGINS_DOWNLOAD` and the Git VFS
plugin arrive together.
+`HOP_DRIVERS_DOWNLOAD` is already in 2.19.
+
+=== Drivers and plugins
+
+Set `HOP_DRIVERS_DOWNLOAD` to a comma-separated list of driver ids, and
`HOP_DRIVERS_ACCEPT_LICENSE=true` when one of them is a restricted driver
(Oracle, MySQL, MariaDB, DB2, ...).
+`HOP_DRIVERS_MAVEN_REPO` points the download at an internal Nexus or
Artifactory.
+See xref:docker-container.adoc#DownloadingJDBCDrivers[Downloading JDBC
drivers].
+A jar that `hop driver list` does not know is still a file you mount.
+
+Set `HOP_PLUGINS_DOWNLOAD` to the marketplace plugins the run needs.
+The Git driver is one of them: `hop-tech-git-vfs`.
+The stock image does not contain it.
+A private plugin and that Apache plugin can be listed together;
`HOP_PLUGINS_MAVEN_REPO` is tried first and the other configured repositories
are still used.
+See xref:docker-container.adoc#DownloadingMarketplacePlugins[Downloading
Marketplace plugins].
+
+A later start skips a plugin whose receipt version is already on disk.
+Drivers are downloaded on every start.
+`lib/jdbc` and the plugins directory have to be writable.
+On a read-only root filesystem, point `HOP_SHARED_JDBC_FOLDERS` and
`HOP_PLUGIN_BASE_FOLDERS` at a volume.
+
+=== The project you mount
+
+Set `HOP_PROJECT_FOLDER` to a real directory.
+Hop resolves that home before it loads named VFS connections, and the Git
scheme exists only after the connection has been read from a folder Hop can
already open.
+
+Mount a small project that holds the connection and the metadata the run needs.
+The pipelines and workflows stay in the remote repository.
+The mount in the example is read-only, so `project-config.json` has to be in
it already.
+The entrypoint keeps that file (`--project-keep-config-file`).
+A missing file would be created with *Enforce executions in project home*
turned on, and hop-run would then refuse `ops:///workflows/daily.hwf` because
that path is outside `/bootstrap`.
+
+[source,json]
+----
+{
+ "metadataBaseFolder" : "${PROJECT_HOME}/metadata",
+ "parentProjectName" : "default",
+ "enforcingExecutionInHome" : false
+}
+----
+
+`parentProjectName` set to `default` picks up the `local` run configuration
shipped in the image.
+The `metadata/` directory inside the git repository is a different folder.
+Hop reads metadata from the bootstrap project (and from that `default`
parent), which is where database connections the pipeline uses belong.
+
+[source,bash]
+----
+docker run --rm \
+ -e HOP_DRIVERS_DOWNLOAD="oracle,mariadb:3.4.1" \
+ -e HOP_DRIVERS_ACCEPT_LICENSE=true \
+ -e HOP_PLUGINS_DOWNLOAD="hop-tech-git-vfs" \
+ -e HOP_PROJECT_FOLDER=/bootstrap \
+ -e HOP_PROJECT_NAME=bootstrap \
+ -e HOP_ENVIRONMENT_NAME=prod \
+ -e HOP_ENVIRONMENT_CONFIG_FILE_NAME_PATHS=/config/prod.json \
+ -e HOP_FILE_PATH='ops:///workflows/daily.hwf' \
+ -e HOP_RUN_CONFIG=local \
+ -v /etc/hop/bootstrap:/bootstrap:ro \
+ -v /etc/hop/environments/prod.json:/config/prod.json:ro \
+ -v /etc/hop/secrets/deploy-key:/run/secrets/deploy-key:ro \
+ -v /etc/hop/secrets/known_hosts:/run/secrets/known_hosts:ro \
+ apache/hop:<version>
+----
+
+`/bootstrap` also needs `metadata/git-vfs-connection/ops.json`.
+`HOP_RUN_CONFIG=local` resolves through the `default` parent, so you only add
a run configuration file when the run should use a different one.
+
+Omit `HOP_FILE_PATH` and `HOP_RUN_CONFIG` and the same container starts Hop
Server instead of hop-run.
+The server still needs the bootstrap project, the connection and the key.
+
+The connection for an SSH deploy key, GitHub or GitLab, looks like this.
+`authType` is the name of the enum value.
+`name` is the VFS scheme.
+
+[source,json]
+----
+{
+ "name" : "ops",
+ "repositoryUrl" : "[email protected]:data/your-project.git",
+ "revision" : "a1b2c3d4e5f6789012345678901234567890abcd",
+ "authType" : "DEPLOY_KEY",
+ "privateKeyFile" : "/run/secrets/deploy-key",
+ "sshUser" : "git",
+ "knownHostsFile" : "/run/secrets/known_hosts",
+ "readOnly" : true
+}
+----
+
+A GitLab deploy token is the same file with `authType` set to
`USERNAME_PASSWORD`, an `https://` repository URL, the token's user name, and
the token as the password.
+The token needs the `read_repository` scope.
+Leave the token out of the URL.
+
+Pin `revision` to a commit id.
+Leave the checkout folder empty on a short-lived container: the working copy
goes under the temporary directory and disappears with the container.
+A long-lived server should set a checkout folder and a maximum checkout age,
so a later commit on a branch is picked up without a restart.
+See xref:metadata-types/git-vfs-connection.adoc[Git repository].
+
+Mount a `known_hosts` file, as in the example.
+`acceptUnknownHosts` set to `true` is the alternative when the host key cannot
be supplied.
+A fresh container has no `~/.ssh/known_hosts`, and the connection refuses the
server until one of those two is set.
+
+=== What the paths mean
+
+`ops:///workflows/daily.hwf` is that file in the repository the connection
named `ops` checked out.
+
+`+${PROJECT_HOME}+` is `/bootstrap`, the folder you mounted.
+A pipeline stored as `+${PROJECT_HOME}+/pipelines/load.hpl` resolves against
that folder.
+
+A path relative to the file that is running stays on the git scheme.
+For a workflow opened as `ops:///workflows/daily.hwf`,
`+${Internal.Entry.Current.Folder}+` is the `workflows` directory inside the
checkout, so a workflow that refers to its neighbours that way keeps reading
the repository.
+
+A project that reaches its files through `+${PROJECT_HOME}+` still wants
xref:hop-server/deploy-project-image.adoc[an image] or
xref:hop-server/deploy-git-checkout.adoc[a checkout].
+
+The container needs the git host on every start.
+The connection names the revision, and the image tag names the Hop version.
+
== You often do not need a Hop Server
If the project is already in an image, a scheduler can start that image as a
*short-lived* container (locally, or on Docker, Kubernetes, OpenShift, ...),
run one pipeline or workflow, and exit.
@@ -114,6 +255,7 @@ There is no long-lived Hop Server to keep alive, upgrade or
authenticate.
The official `apache/hop` image does both jobs.
Set `HOP_FILE_PATH` and `HOP_RUN_CONFIG` and the entrypoint runs
xref:hop-run/index.adoc[hop-run] and exits.
Omit them and it starts Hop Server.
+On the <<StandardApacheHopContainer,stock image>>, `HOP_FILE_PATH` can be a
Git VFS URL.
Because the container disappears when the run finishes, send
xref:metadata-types/execution-information-location.adoc[execution information]
somewhere that outlives it: a mounted volume (file or caching-file location), a
relational database, OpenSearch, Neo4j or Elastic.
You can inspect those runs later from the
xref:hop-gui/perspective-execution-information.adoc[Execution Information
perspective] in Hop Gui.
diff --git
a/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/catalog/PluginDiscovery.java
b/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/catalog/PluginDiscovery.java
index fa788babd6..da3139f461 100644
---
a/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/catalog/PluginDiscovery.java
+++
b/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/catalog/PluginDiscovery.java
@@ -45,8 +45,16 @@ public final class PluginDiscovery {
/**
* Resolved install target: Maven coordinates plus an optional preferred
repository id (from
* discovery {@code source}) tried first in the install fallback chain.
+ *
+ * @param discovered true when the coordinate came from a catalog or browse
hit, false when it is
+ * a literal parse (including the {@code org.apache.hop} fallback)
*/
- public record InstallTarget(MavenCoordinates coordinates, String
preferredRepoId) {}
+ public record InstallTarget(
+ MavenCoordinates coordinates, String preferredRepoId, boolean
discovered) {
+ public InstallTarget(MavenCoordinates coordinates, String preferredRepoId)
{
+ this(coordinates, preferredRepoId, false);
+ }
+ }
/**
* Full marketplace discovery: bundled Apache optional catalog plus every
enabled repository with
@@ -193,7 +201,8 @@ public final class PluginDiscovery {
preferredRepo = chosen.getSource();
}
- return new InstallTarget(new MavenCoordinates(groupId, artifactId,
version), preferredRepo);
+ return new InstallTarget(
+ new MavenCoordinates(groupId, artifactId, version), preferredRepo,
true);
}
/**
diff --git
a/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/command/MarketplaceCommand.java
b/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/command/MarketplaceCommand.java
index b734abd648..49c64d625f 100644
---
a/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/command/MarketplaceCommand.java
+++
b/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/command/MarketplaceCommand.java
@@ -23,6 +23,7 @@ import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
+import java.util.Locale;
import java.util.Set;
import java.util.stream.Collectors;
import lombok.Getter;
@@ -141,7 +142,7 @@ public class MarketplaceCommand implements Runnable,
IHopCommand, IHasHopMetadat
+ " Every coordinate is resolved before the first download, so a
typo fails before"
+ " anything is fetched. A plugin that fails to install does not
stop the others;"
+ " the command exits non-zero when any of them failed.")
- static class InstallCommand extends MarketplaceSubCommand {
+ public static class InstallCommand extends MarketplaceSubCommand {
@Parameters(
index = "0",
arity = "1..*",
@@ -155,17 +156,79 @@ public class MarketplaceCommand implements Runnable,
IHopCommand, IHasHopMetadat
@Option(
names = {"--repo"},
description =
- "Use only this repository id (skip fallback chain). Default:
prefer discovery source,"
- + " then primary and other enabled repos.")
+ "Use only this repository id (skip fallback chain) or base URL.
Default: prefer discovery"
+ + " source, then primary and other enabled repos.")
private String repoId;
+ @Option(
+ names = {"--repo-url"},
+ description =
+ "Maven repository base URL to try first (corporate Artifactory,
Nexus, or a plain"
+ + " Maven repository). Other configured repositories are still
used when the"
+ + " plugin is not there. Pass --repo <id> (not a URL) to use
only one repository.")
+ private String repoUrl;
+
+ @Option(
+ names = {"--repo-id"},
+ description =
+ "Repository id created for --repo-url. Defaults to the
HOP_PLUGINS_REPO_ID"
+ + " environment variable, or corporate-repo. Credentials are
read from"
+ + " HOP_MARKETPLACE_<ID>_USERNAME,
HOP_MARKETPLACE_<ID>_PASSWORD and"
+ + " HOP_MARKETPLACE_<ID>_TOKEN.")
+ private String adHocRepoId;
+
+ @Option(
+ names = {"--username"},
+ description = "Optional Basic auth username for the repository
specified in --repo-url")
+ private String username;
+
+ @Option(
+ names = {"--password"},
+ description =
+ "Optional Basic auth password or bearer token for the repository
specified in"
+ + " --repo-url. Prefer HOP_MARKETPLACE_<ID>_PASSWORD or _TOKEN
so the secret is"
+ + " not on the process command line.")
+ private String password;
+
+ @Option(
+ names = {"--auth-type"},
+ description = "Authentication type for --repo-url: auto (default),
none, basic or token")
+ private String authType;
+
+ @Option(
+ names = {"--repo-type"},
+ description =
+ "Browse API for --repo-url: auto (default), nexus, forgejo, jfrog,
or maven. maven is"
+ + " a plain repository that cannot be browsed; pass
groupId:artifactId:version.")
+ private String repoType;
+
@Override
public void run() {
try {
+ if (log == null) {
+ log = new LogChannel("Marketplace");
+ }
MarketplaceConfig config = MarketplaceConfig.load();
if (!config.isEnabled()) {
throw new HopException("Marketplace is disabled in hop-config.json");
}
+
+ // If repoId is an HTTP/HTTPS URL and repoUrl was omitted, treat it as
repoUrl
+ if (StringUtils.isBlank(repoUrl)
+ && StringUtils.isNotBlank(repoId)
+ && (repoId.startsWith("http://") ||
repoId.startsWith("https://"))) {
+ repoUrl = repoId;
+ repoId = null;
+ }
+
+ MarketplaceRepository adHoc = registerAdHocRepository(config);
+ // --repo <id> stays exclusive. --repo-url is preferred and still
falls back, including
+ // when --repo was only the URL we just turned into the ad-hoc
repository.
+ String forceRepoId = null;
+ if (StringUtils.isNotBlank(repoId) && (adHoc == null ||
!repoId.equals(adHoc.getId()))) {
+ forceRepoId = repoId;
+ }
+
Path hopHome = HopHome.resolve();
PluginInstaller installer = new PluginInstaller(log, hopHome, config);
// Activate any previously staged plugins first
@@ -178,6 +241,9 @@ public class MarketplaceCommand implements Runnable,
IHopCommand, IHasHopMetadat
PluginDiscovery.InstallTarget target =
PluginDiscovery.resolveInstall(
coordinate, config.getGroupId(),
resolveDefaultVersion(config), config, log);
+ if (adHoc != null && forceRepoId == null) {
+ rejectUnresolvedShortName(coordinate, target, adHoc);
+ }
printResolution(coordinate, target);
targets.add(target);
}
@@ -192,8 +258,12 @@ public class MarketplaceCommand implements Runnable,
IHopCommand, IHasHopMetadat
// Prints "[2/5] hop-tech-parquet"; silent for a single install.
progress.item(gav.artifactId(), i, targets.size());
try {
+ String preferred = target.preferredRepoId();
+ if (adHoc != null && forceRepoId == null) {
+ preferred = adHoc.getId();
+ }
InstallReceipt receipt =
- installer.install(gav, true, repoId,
target.preferredRepoId(), progress);
+ installer.install(gav, true, forceRepoId, preferred,
progress);
installed++;
System.out.println(installedMessage(gav, receipt, hopHome,
targets.size() == 1));
} catch (Exception e) {
@@ -257,6 +327,9 @@ public class MarketplaceCommand implements Runnable,
IHopCommand, IHasHopMetadat
private static String installedMessage(
MavenCoordinates gav, InstallReceipt receipt, Path hopHome, boolean
single) {
+ if (receipt.isAlreadyPresent()) {
+ return "Plugin " + gav.gav() + " is already installed under " +
hopHome + ".";
+ }
return "Plugin "
+ gav.gav()
+ " installed under "
@@ -267,6 +340,89 @@ public class MarketplaceCommand implements Runnable,
IHopCommand, IHasHopMetadat
// For a batch the restart hint belongs on the summary line, not on
every plugin.
+ (single ? ". Restart Hop to load it." : ".");
}
+
+ /**
+ * Default id is {@code corporate-repo}, matching {@code
HOP_PLUGINS_REPO_ID}. An id that is
+ * already configured is reused so credential variable names stay stable
across starts.
+ */
+ private MarketplaceRepository registerAdHocRepository(MarketplaceConfig
config) {
+ if (StringUtils.isBlank(repoUrl)) {
+ return null;
+ }
+ String id = resolveAdHocRepoId();
+ MarketplaceRepository adHoc = config.findRepository(id);
+ if (adHoc == null) {
+ adHoc = new MarketplaceRepository(id, "Ad-hoc Repository", repoUrl,
false);
+ if (config.getRepositories() == null) {
+ config.setRepositories(new ArrayList<>());
+ }
+ config.getRepositories().add(0, adHoc);
+ } else {
+ adHoc.setUrl(repoUrl);
+ adHoc.setEnabled(true);
+ }
+ if (StringUtils.isNotBlank(username)) {
+ adHoc.setUsername(username);
+ }
+ if (StringUtils.isNotBlank(password)) {
+ adHoc.setPassword(password);
+ }
+ if (StringUtils.isNotBlank(authType)) {
+ adHoc.setAuthType(authType);
+ }
+ applyRepoType(adHoc, repoType);
+ return adHoc;
+ }
+
+ private String resolveAdHocRepoId() {
+ if (StringUtils.isNotBlank(adHocRepoId)) {
+ return adHocRepoId.trim();
+ }
+ String fromEnv = System.getenv("HOP_PLUGINS_REPO_ID");
+ if (StringUtils.isNotBlank(fromEnv)) {
+ return fromEnv.trim();
+ }
+ return "corporate-repo";
+ }
+
+ static void applyRepoType(MarketplaceRepository repo, String repoType) {
+ String type = StringUtils.trimToEmpty(repoType);
+ if (type.isEmpty() ||
MarketplaceRepository.BROWSER_AUTO.equalsIgnoreCase(type)) {
+ repo.setBrowserType(MarketplaceRepository.BROWSER_AUTO);
+ repo.setBrowse(repo.supportsBrowseApi());
+ return;
+ }
+ if ("maven".equalsIgnoreCase(type) || "none".equalsIgnoreCase(type)) {
+ repo.setBrowserType(MarketplaceRepository.BROWSER_AUTO);
+ repo.setBrowse(false);
+ return;
+ }
+ repo.setBrowserType(type.toLowerCase(Locale.ROOT));
+ repo.setBrowse(true);
+ }
+
+ /**
+ * A short name against a repository that cannot be browsed must not be
rewritten to {@code
+ * org.apache.hop:artifact:version}. A full {@code
groupId:artifactId:version} is unchanged, and
+ * a name that discovery already resolved (the Apache catalog, for
example) is kept.
+ */
+ static void rejectUnresolvedShortName(
+ String coordinate, PluginDiscovery.InstallTarget target,
MarketplaceRepository adHoc)
+ throws HopException {
+ if (adHoc == null || target == null || target.discovered() ||
adHoc.canBrowse()) {
+ return;
+ }
+ String trimmed = coordinate == null ? "" : coordinate.trim();
+ if (trimmed.split(":", -1).length >= 3) {
+ return;
+ }
+ throw new HopException(
+ "Repository '"
+ + adHoc.getId()
+ + "' cannot be browsed, so '"
+ + coordinate
+ + "' cannot be resolved. Pass groupId:artifactId:version.");
+ }
}
@Command(
diff --git
a/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/config/MarketplaceRepository.java
b/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/config/MarketplaceRepository.java
index 9eddec0fe9..99b567f78b 100644
---
a/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/config/MarketplaceRepository.java
+++
b/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/config/MarketplaceRepository.java
@@ -29,6 +29,7 @@ import java.util.function.UnaryOperator;
import lombok.Getter;
import lombok.Setter;
import org.apache.commons.lang3.StringUtils;
+import org.apache.hop.marketplace.catalog.NexusRepositoryBrowser;
import org.apache.hop.marketplace.catalog.OptionalPluginInfo;
@Getter
@@ -277,12 +278,12 @@ public class MarketplaceRepository {
}
/**
- * Environment lookup, replaceable in tests. Package-private on purpose:
credential resolution is
- * otherwise untestable, and it is the part most likely to go subtly wrong.
+ * Environment lookup, replaceable in tests. Credential resolution is
otherwise untestable, and it
+ * is the part most likely to go subtly wrong.
*/
private static UnaryOperator<String> environment = System::getenv;
- static void setEnvironmentForTesting(UnaryOperator<String> lookup) {
+ public static void setEnvironmentForTesting(UnaryOperator<String> lookup) {
environment = lookup == null ? System::getenv : lookup;
}
@@ -363,6 +364,31 @@ public class MarketplaceRepository {
return StringUtils.isAllBlank(username, password) && hasCredentials();
}
+ /**
+ * True when this repository can list plugins (a catalog URL, or a browse
API the URL or {@link
+ * #browserType} actually supports). A plain Maven repository is not
browsable: callers must pass
+ * {@code groupId:artifactId:version} instead of a short name.
+ */
+ public boolean canBrowse() {
+ return isBrowse() && supportsBrowseApi();
+ }
+
+ /**
+ * Whether the resolved browse backend can list this repository, ignoring
the {@link #browse}
+ * flag. Explicit {@code jfrog} or {@code forgejo} counts; {@code nexus}
only when the URL is a
+ * Nexus {@code /repository/<name>/} base.
+ */
+ public boolean supportsBrowseApi() {
+ if (StringUtils.isNotBlank(catalogUrl)) {
+ return true;
+ }
+ return switch (effectiveBrowserType()) {
+ case BROWSER_FORGEJO, BROWSER_JFROG -> true;
+ case BROWSER_NEXUS -> NexusRepositoryBrowser.isNexusBrowseUrl(url);
+ default -> false;
+ };
+ }
+
/**
* Resolved browse backend. When {@link #browserType} is blank or {@code
auto}, the URL decides: a
* Forgejo / Gitea package registry ({@code .../api/packages/{owner}/maven})
selects {@code
diff --git
a/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/install/HopHome.java
b/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/install/HopHome.java
index 2c3e08b99d..35ffa42ee5 100644
---
a/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/install/HopHome.java
+++
b/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/install/HopHome.java
@@ -21,6 +21,7 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
+import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
@@ -42,9 +43,36 @@ public final class HopHome {
public static Path resolve() throws HopException {
List<String> tried = new ArrayList<>();
- for (Path candidate : candidates()) {
+ Path imageHome = null;
+ for (Path candidate : imageCandidates()) {
Path abs = candidate.toAbsolutePath().normalize();
tried.add(abs.toString());
+ if (isHopHome(abs)) {
+ imageHome = abs;
+ break;
+ }
+ }
+
+ // A writable plugins directory named in HOP_PLUGIN_BASE_FOLDERS (usually
a mounted volume) is
+ // the install target, so a read-only image root is not the only place
plugins can live. The
+ // launcher passes the variable as a system property; the environment is
only a fallback when
+ // the process was not started by that launcher.
+ for (Path home : pluginHomes(true)) {
+ Path abs = home.toAbsolutePath().normalize();
+ if (imageHome != null && imageHome.equals(abs)) {
+ continue;
+ }
+ if (isWritablePluginsHome(abs)) {
+ return abs;
+ }
+ }
+ if (imageHome != null) {
+ return imageHome;
+ }
+
+ for (Path home : pluginHomes(false)) {
+ Path abs = home.toAbsolutePath().normalize();
+ tried.add(abs.toString());
if (isHopHome(abs)) {
return abs;
}
@@ -56,7 +84,7 @@ public final class HopHome {
+ String.join(", ", tried));
}
- private static Set<Path> candidates() {
+ private static Set<Path> imageCandidates() {
Set<Path> paths = new LinkedHashSet<>();
Path cwd = Paths.get(System.getProperty("user.dir", "."));
paths.add(cwd);
@@ -65,16 +93,27 @@ public final class HopHome {
paths.add(parent);
}
- // Web deployments can keep the writable plugins and configuration outside
the binaries.
- // Resolve those configured locations without changing the launcher
behavior above.
+ // Web deployments can keep configuration outside the binaries.
addConfiguredParent(paths, System.getProperty(HOP_CONFIG_FOLDER),
"config");
addConfiguredParent(paths, System.getenv(HOP_CONFIG_FOLDER), "config");
- addConfiguredPluginParents(paths,
System.getProperty(HOP_PLUGIN_BASE_FOLDERS));
- addConfiguredPluginParents(paths, System.getenv(HOP_PLUGIN_BASE_FOLDERS));
return paths;
}
- private static void addConfiguredPluginParents(Set<Path> paths, String
configured) {
+ private static List<Path> pluginHomes(boolean propertyOnly) {
+ List<Path> homes = new ArrayList<>();
+ addConfiguredPluginParents(homes,
System.getProperty(HOP_PLUGIN_BASE_FOLDERS));
+ if (!propertyOnly) {
+ addConfiguredPluginParents(homes,
System.getenv(HOP_PLUGIN_BASE_FOLDERS));
+ }
+ return homes;
+ }
+
+ private static boolean isWritablePluginsHome(Path home) {
+ Path plugins = home.resolve("plugins");
+ return Files.isDirectory(plugins) && Files.isWritable(plugins);
+ }
+
+ private static void addConfiguredPluginParents(Collection<Path> paths,
String configured) {
if (configured == null || configured.isBlank()) {
return;
}
@@ -83,7 +122,8 @@ public final class HopHome {
}
}
- private static void addConfiguredParent(Set<Path> paths, String configured,
String childName) {
+ private static void addConfiguredParent(
+ Collection<Path> paths, String configured, String childName) {
if (configured == null || configured.isBlank()) {
return;
}
diff --git
a/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/install/InstallReceipt.java
b/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/install/InstallReceipt.java
index 12edeb6063..9b4e6f0d64 100644
---
a/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/install/InstallReceipt.java
+++
b/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/install/InstallReceipt.java
@@ -17,6 +17,7 @@
package org.apache.hop.marketplace.install;
+import com.fasterxml.jackson.annotation.JsonIgnore;
import java.util.ArrayList;
import java.util.List;
import lombok.Getter;
@@ -39,6 +40,9 @@ public class InstallReceipt {
private List<String> paths = new ArrayList<>();
private boolean pendingActivation;
+ /** Set when {@code install} returned this receipt without downloading
again. Not persisted. */
+ @JsonIgnore private boolean alreadyPresent;
+
public InstallReceipt() {
// Jackson
}
diff --git
a/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/install/PluginInstaller.java
b/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/install/PluginInstaller.java
index 79afdcbb42..8096c5d026 100644
---
a/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/install/PluginInstaller.java
+++
b/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/install/PluginInstaller.java
@@ -36,6 +36,7 @@ import org.apache.hop.core.Const;
import org.apache.hop.core.exception.HopException;
import org.apache.hop.core.json.HopJson;
import org.apache.hop.core.logging.ILogChannel;
+import org.apache.hop.core.logging.LogChannel;
import org.apache.hop.marketplace.config.MarketplaceConfig;
import org.apache.hop.marketplace.config.MarketplaceRepository;
import org.apache.hop.marketplace.resolve.MavenCoordinates;
@@ -57,15 +58,16 @@ public class PluginInstaller {
private final MavenRepositoryClient client;
public PluginInstaller(ILogChannel log, Path hopHome, MarketplaceConfig
config) {
- this.log = log;
- this.hopHome = hopHome;
- this.config = config;
- this.client = new MavenRepositoryClient(log);
+ this(
+ log != null ? log : new LogChannel("PluginInstaller"),
+ hopHome,
+ config,
+ new MavenRepositoryClient(log != null ? log : new
LogChannel("PluginInstaller")));
}
PluginInstaller(
ILogChannel log, Path hopHome, MarketplaceConfig config,
MavenRepositoryClient client) {
- this.log = log;
+ this.log = log != null ? log : new LogChannel("PluginInstaller");
this.hopHome = hopHome;
this.config = config;
this.client = client;
@@ -119,6 +121,17 @@ public class PluginInstaller {
IInstallListener listener)
throws HopException {
IInstallListener progress = listener == null ? IInstallListener.NONE :
listener;
+ InstallReceipt already = satisfiedInstall(coordinates);
+ if (already != null) {
+ already.setAlreadyPresent(true);
+ log.logBasic(
+ "Plugin "
+ + coordinates.gav()
+ + " is already installed (receipt "
+ + already.getVersion()
+ + "); skipping download.");
+ return already;
+ }
Path downloadDir = hopHome.resolve(STAGING_DIR).resolve(".download");
Path zipFile =
downloadDir.resolve(coordinates.artifactId() + "-" +
coordinates.version() + ".zip");
@@ -197,6 +210,49 @@ public class PluginInstaller {
}
}
+ /**
+ * A repeated install can skip the download when the receipt names this
version and every file it
+ * recorded is still on disk.
+ */
+ private InstallReceipt satisfiedInstall(MavenCoordinates coordinates) throws
HopException {
+ InstallReceipt receipt = readReceipt(hopHome, coordinates.artifactId());
+ if (receipt == null || receipt.isPendingActivation()) {
+ return null;
+ }
+ if (!coordinates.version().equals(receipt.getVersion())) {
+ return null;
+ }
+ if (StringUtils.isNotBlank(receipt.getGroupId())
+ && !receipt.getGroupId().equals(coordinates.groupId())) {
+ return null;
+ }
+ if (!receiptFilesPresent(receipt)) {
+ return null;
+ }
+ return receipt;
+ }
+
+ private boolean receiptFilesPresent(InstallReceipt receipt) {
+ List<String> paths = receipt.getPaths();
+ if (paths == null || paths.isEmpty()) {
+ return false;
+ }
+ boolean file = false;
+ for (String relative : paths) {
+ if (StringUtils.isBlank(relative)) {
+ continue;
+ }
+ Path path = activationTarget(relative);
+ if (!Files.exists(path)) {
+ return false;
+ }
+ if (Files.isRegularFile(path)) {
+ file = true;
+ }
+ }
+ return file;
+ }
+
private List<MarketplaceRepository> resolveRepositories(
String forceRepoId, String preferredRepoId) throws HopException {
if (StringUtils.isNotBlank(forceRepoId)) {
@@ -254,7 +310,7 @@ public class PluginInstaller {
try {
for (String relative : relativePaths) {
Path from = stageRoot.resolve(relative);
- Path to = hopHome.resolve(relative);
+ Path to = activationTarget(relative);
if (Files.isDirectory(from)) {
Files.createDirectories(to);
} else if (Files.isRegularFile(from)) {
@@ -324,6 +380,38 @@ public class PluginInstaller {
return normalized.equals("lib/core") || normalized.startsWith("lib/core/");
}
+ /** Web layout keeps shared jars in {@code WEB-INF/lib} instead of {@code
lib/core}. */
+ private Path activationTarget(String relative) {
+ Path standard = hopHome.resolve(relative);
+ String normalized = relative == null ? "" : relative.replace('\\', '/');
+ if (!normalized.startsWith("lib/core/") || normalized.endsWith("/")) {
+ return standard;
+ }
+ Path webLib = webInfLib();
+ if (webLib == null) {
+ return standard;
+ }
+ Path name = Path.of(normalized).getFileName();
+ return name == null ? standard : webLib.resolve(name);
+ }
+
+ private Path webInfLib() {
+ Path fromCwd =
+ Path.of(System.getProperty("user.dir", "."))
+ .resolve("WEB-INF")
+ .resolve("lib")
+ .toAbsolutePath()
+ .normalize();
+ if (Files.isDirectory(fromCwd)) {
+ return fromCwd;
+ }
+ Path fromHome =
hopHome.resolve("webapps").resolve("ROOT").resolve("WEB-INF").resolve("lib");
+ if (Files.isDirectory(fromHome)) {
+ return fromHome;
+ }
+ return null;
+ }
+
/**
* Whether activation should skip copying a staged lib/core file onto an
existing target.
*
diff --git
a/plugins/misc/marketplace/src/test/java/org/apache/hop/marketplace/catalog/PluginDiscoveryResolveInstallTest.java
b/plugins/misc/marketplace/src/test/java/org/apache/hop/marketplace/catalog/PluginDiscoveryResolveInstallTest.java
index 1606d44774..28b9248dd5 100644
---
a/plugins/misc/marketplace/src/test/java/org/apache/hop/marketplace/catalog/PluginDiscoveryResolveInstallTest.java
+++
b/plugins/misc/marketplace/src/test/java/org/apache/hop/marketplace/catalog/PluginDiscoveryResolveInstallTest.java
@@ -18,6 +18,7 @@
package org.apache.hop.marketplace.catalog;
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.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -61,6 +62,7 @@ class PluginDiscoveryResolveInstallTest {
"org.example:foo:9.9.9", "org.apache.hop", "2.19.0-SNAPSHOT",
null, null);
assertEquals("org.example:foo:9.9.9", target.coordinates().gav());
assertNull(target.preferredRepoId());
+ assertFalse(target.discovered());
}
@Test
@@ -70,6 +72,7 @@ class PluginDiscoveryResolveInstallTest {
"totally-unknown-plugin", "org.apache.hop", "2.19.0-SNAPSHOT",
null, null);
assertEquals(
"org.apache.hop:totally-unknown-plugin:2.19.0-SNAPSHOT",
target.coordinates().gav());
+ assertFalse(target.discovered());
}
private static OptionalPluginInfo plugin(String artifactId, String version,
String source) {
diff --git
a/plugins/misc/marketplace/src/test/java/org/apache/hop/marketplace/command/InstallCommandParsingTest.java
b/plugins/misc/marketplace/src/test/java/org/apache/hop/marketplace/command/InstallCommandParsingTest.java
index ef106bafd6..6d2789cafa 100644
---
a/plugins/misc/marketplace/src/test/java/org/apache/hop/marketplace/command/InstallCommandParsingTest.java
+++
b/plugins/misc/marketplace/src/test/java/org/apache/hop/marketplace/command/InstallCommandParsingTest.java
@@ -19,8 +19,13 @@ package org.apache.hop.marketplace.command;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.marketplace.catalog.PluginDiscovery;
+import org.apache.hop.marketplace.config.MarketplaceRepository;
+import org.apache.hop.marketplace.resolve.MavenCoordinates;
import org.junit.jupiter.api.Test;
import picocli.CommandLine;
@@ -57,6 +62,80 @@ class InstallCommandParsingTest {
assertEquals("local-nexus", parsed.matchedOptionValue("--repo", null));
}
+ @Test
+ void repoUrlAndCredentialsOptionsAreParsed() {
+ CommandLine commandLine = new CommandLine(new
MarketplaceCommand.InstallCommand());
+ CommandLine.ParseResult parsed =
+ commandLine.parseArgs(
+ "--repo-url",
+
"https://repository.data-hopper.com/repository/hop-community-plugins/",
+ "--username",
+ "testuser",
+ "--password",
+ "testpass",
+ "--auth-type",
+ "basic",
+ "org.hopper:hopper-edw:0.10.0");
+ assertEquals(List.of("org.hopper:hopper-edw:0.10.0"),
parsed.matchedPositional(0).getValue());
+ assertEquals(
+ "https://repository.data-hopper.com/repository/hop-community-plugins/",
+ parsed.matchedOptionValue("--repo-url", null));
+ assertEquals("testuser", parsed.matchedOptionValue("--username", null));
+ assertEquals("testpass", parsed.matchedOptionValue("--password", null));
+ assertEquals("basic", parsed.matchedOptionValue("--auth-type", null));
+ }
+
+ @Test
+ void repoIdAndRepoTypeOptionsAreParsed() {
+ CommandLine commandLine = new CommandLine(new
MarketplaceCommand.InstallCommand());
+ CommandLine.ParseResult parsed =
+ commandLine.parseArgs(
+ "--repo-url",
+ "https://maven.example/releases/",
+ "--repo-id",
+ "corporate-repo",
+ "--repo-type",
+ "maven",
+ "com.acme:acme-plugin:1.0.0");
+ assertEquals("corporate-repo", parsed.matchedOptionValue("--repo-id",
null));
+ assertEquals("maven", parsed.matchedOptionValue("--repo-type", null));
+ }
+
+ @Test
+ void shortNameOnUnbrowsableRepositoryAsksForFullCoordinates() {
+ MarketplaceRepository repo =
+ new MarketplaceRepository("corporate-repo",
"https://maven.example/releases/");
+ repo.setBrowse(false);
+ PluginDiscovery.InstallTarget target =
+ new PluginDiscovery.InstallTarget(
+ new MavenCoordinates("org.apache.hop", "acme-plugin", "1.0.0"),
null);
+ HopException ex =
+ assertThrows(
+ HopException.class,
+ () ->
+ MarketplaceCommand.InstallCommand.rejectUnresolvedShortName(
+ "acme-plugin:1.0.0", target, repo));
+ assertTrue(ex.getMessage().contains("cannot be browsed"));
+ assertTrue(ex.getMessage().contains("groupId:artifactId:version"));
+ }
+
+ @Test
+ void
fullCoordinateAndDiscoveredNameAreAcceptedWhenRepositoryCannotBeBrowsed()
throws Exception {
+ MarketplaceRepository repo =
+ new MarketplaceRepository("corporate-repo",
"https://maven.example/releases/");
+ repo.setBrowse(false);
+ MarketplaceCommand.InstallCommand.rejectUnresolvedShortName(
+ "com.acme:acme-plugin:1.0.0",
+ new PluginDiscovery.InstallTarget(
+ new MavenCoordinates("com.acme", "acme-plugin", "1.0.0"), null),
+ repo);
+ MarketplaceCommand.InstallCommand.rejectUnresolvedShortName(
+ "hop-tech-parquet",
+ new PluginDiscovery.InstallTarget(
+ new MavenCoordinates("org.apache.hop", "hop-tech-parquet",
"2.20.0"), null, true),
+ repo);
+ }
+
@Test
void atLeastOneCoordinateIsRequired() {
assertThrows(CommandLine.MissingParameterException.class,
this::parseNothing);
diff --git
a/plugins/misc/marketplace/src/test/java/org/apache/hop/marketplace/config/MarketplaceRepositoryBrowserTypeTest.java
b/plugins/misc/marketplace/src/test/java/org/apache/hop/marketplace/config/MarketplaceRepositoryBrowserTypeTest.java
index 7713f3b1b8..5157f73622 100644
---
a/plugins/misc/marketplace/src/test/java/org/apache/hop/marketplace/config/MarketplaceRepositoryBrowserTypeTest.java
+++
b/plugins/misc/marketplace/src/test/java/org/apache/hop/marketplace/config/MarketplaceRepositoryBrowserTypeTest.java
@@ -199,4 +199,35 @@ class MarketplaceRepositoryBrowserTypeTest {
"https://example.org/${artifactId}-${version}.zip",
config.findRepository("acme").getUrlTemplate());
}
+
+ @Test
+ void plainMavenRepositoryCannotBeBrowsed() {
+ MarketplaceRepository repo =
+ new MarketplaceRepository("corporate-repo",
"https://maven.example/releases/");
+ repo.setBrowse(true);
+ assertFalse(repo.supportsBrowseApi());
+ assertFalse(repo.canBrowse());
+ }
+
+ @Test
+ void nexusArtifactoryAndExplicitTypeCanBeBrowsed() {
+ MarketplaceRepository nexus =
+ new MarketplaceRepository("nexus",
"https://repository.example/repository/hop-plugins/");
+ nexus.setBrowse(true);
+ assertTrue(nexus.canBrowse());
+
+ MarketplaceRepository artifactory =
+ new MarketplaceRepository(
+ "artifactory",
"https://artifactory.example/artifactory/hop-plugins/");
+ artifactory.setBrowse(true);
+ assertTrue(artifactory.canBrowse());
+
+ MarketplaceRepository explicit = new MarketplaceRepository("jfrog",
"https://maven.example/m/");
+ explicit.setBrowserType(MarketplaceRepository.BROWSER_JFROG);
+ explicit.setBrowse(true);
+ assertTrue(explicit.canBrowse());
+
+ explicit.setBrowse(false);
+ assertFalse(explicit.canBrowse());
+ }
}
diff --git
a/plugins/misc/marketplace/src/test/java/org/apache/hop/marketplace/install/HopHomeTest.java
b/plugins/misc/marketplace/src/test/java/org/apache/hop/marketplace/install/HopHomeTest.java
index 4caf4c9ee9..a6c44b9c2f 100644
---
a/plugins/misc/marketplace/src/test/java/org/apache/hop/marketplace/install/HopHomeTest.java
+++
b/plugins/misc/marketplace/src/test/java/org/apache/hop/marketplace/install/HopHomeTest.java
@@ -83,6 +83,25 @@ class HopHomeTest {
}
}
+ @Test
+ void resolvePrefersWritablePluginFolderOverTheInstall() throws Exception {
+ Path image = tempDir.resolve("opt-hop");
+ Files.createDirectories(image.resolve("plugins"));
+ Path volume = tempDir.resolve("volume");
+ Files.createDirectories(volume.resolve("plugins"));
+ String previousUserDir = System.getProperty("user.dir");
+ String previousPluginFolders =
System.getProperty("HOP_PLUGIN_BASE_FOLDERS");
+ try {
+ System.setProperty("user.dir", image.toString());
+ System.setProperty(
+ "HOP_PLUGIN_BASE_FOLDERS", image.resolve("plugins") + "," +
volume.resolve("plugins"));
+ assertEquals(volume.toAbsolutePath().normalize(), HopHome.resolve());
+ } finally {
+ restoreProperty("user.dir", previousUserDir);
+ restoreProperty("HOP_PLUGIN_BASE_FOLDERS", previousPluginFolders);
+ }
+ }
+
@Test
void resolveUsesParentOfConfiguredConfigFolder() throws Exception {
Path hop = tempDir.resolve("hop-web");
diff --git
a/plugins/misc/marketplace/src/test/java/org/apache/hop/marketplace/install/PluginInstallerTest.java
b/plugins/misc/marketplace/src/test/java/org/apache/hop/marketplace/install/PluginInstallerTest.java
index a63e343918..ef904cee97 100644
---
a/plugins/misc/marketplace/src/test/java/org/apache/hop/marketplace/install/PluginInstallerTest.java
+++
b/plugins/misc/marketplace/src/test/java/org/apache/hop/marketplace/install/PluginInstallerTest.java
@@ -25,23 +25,33 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
import com.sun.net.httpserver.HttpServer;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
+import java.io.PrintStream;
+import java.lang.reflect.Field;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
+import java.util.Base64;
+import java.util.LinkedHashMap;
import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicInteger;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
+import org.apache.hop.core.config.HopConfig;
+import org.apache.hop.core.config.plugin.ConfigFile;
import org.apache.hop.core.exception.HopException;
import org.apache.hop.core.logging.HopLogStore;
import org.apache.hop.core.logging.LogChannel;
+import org.apache.hop.marketplace.command.MarketplaceCommand;
import org.apache.hop.marketplace.config.MarketplaceConfig;
import org.apache.hop.marketplace.config.MarketplaceRepository;
import org.apache.hop.marketplace.resolve.MavenCoordinates;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
+import picocli.CommandLine;
class PluginInstallerTest {
@@ -343,6 +353,42 @@ class PluginInstallerTest {
}
}
+ @Test
+ void installCommandWithRepoUrlInstallsFromAdHocRepository() throws Exception
{
+ byte[] zipBytes = buildPluginZip();
+ HttpServer server = zipServer(zipBytes);
+ server.start();
+ try {
+ Path hopHome = tempDir.resolve("hop-adhoc-install");
+ Files.createDirectories(hopHome.resolve("plugins"));
+ int port = server.getAddress().getPort();
+
+ String originalUserDir = System.getProperty("user.dir");
+ try {
+ System.setProperty("user.dir", hopHome.toAbsolutePath().toString());
+ MarketplaceCommand.InstallCommand cmd = new
MarketplaceCommand.InstallCommand();
+ CommandLine cl = new CommandLine(cmd);
+ cl.parseArgs("--repo-url", localUrl(port),
"org.apache.hop:hop-test-plugin:1.0.0");
+ cmd.run();
+
+ Path pluginJar = hopHome.resolve("plugins/tech/test/plugin.jar");
+ assertTrue(Files.isRegularFile(pluginJar));
+ assertTrue(
+ Files.isRegularFile(
+
hopHome.resolve(PluginInstaller.RECEIPTS_DIR).resolve("hop-test-plugin.json")));
+ assertEquals(
+ "corporate-repo",
+ PluginInstaller.readReceipt(hopHome,
"hop-test-plugin").getRepositoryId());
+ } finally {
+ if (originalUserDir != null) {
+ System.setProperty("user.dir", originalUserDir);
+ }
+ }
+ } finally {
+ server.stop(0);
+ }
+ }
+
@Test
void cancelDuringDownloadInstallsNothingAndDoesNotTryOtherRepositories()
throws Exception {
byte[] zipBytes = buildPluginZip();
@@ -389,6 +435,378 @@ class PluginInstallerTest {
}
}
+ @Test
+ void secondInstallSkipsWhenReceiptMatchesAndFilesRemain() throws Exception {
+ byte[] zipBytes = buildPluginZip();
+ AtomicInteger hits = new AtomicInteger();
+ HttpServer server = zipServer(zipBytes, hits);
+ server.start();
+ try {
+ Path hopHome = tempDir.resolve("hop-skip");
+ Files.createDirectories(hopHome.resolve("plugins"));
+ MarketplaceConfig config =
localRepoConfig(server.getAddress().getPort());
+ PluginInstaller installer = new PluginInstaller(new LogChannel("test"),
hopHome, config);
+ MavenCoordinates coords = new MavenCoordinates("org.apache.hop",
"hop-test-plugin", "1.0.0");
+
+ installer.install(coords, true);
+ installer.install(coords, true);
+ assertEquals(
+ 1, hits.get(), "a matching receipt with its files present must not
download again");
+
+ Files.delete(hopHome.resolve("plugins/tech/test/plugin.jar"));
+ installer.install(coords, true);
+ assertEquals(2, hits.get(), "a missing receipt file must be installed
again");
+
assertTrue(Files.isRegularFile(hopHome.resolve("plugins/tech/test/plugin.jar")));
+ } finally {
+ server.stop(0);
+ }
+ }
+
+ @Test
+ void preferredRepositoryIsTriedFirstAndFallsBack() throws Exception {
+ byte[] zipBytes = buildPluginZip();
+ HttpServer server = zipServer(zipBytes);
+ server.start();
+ try {
+ int port = server.getAddress().getPort();
+ Path hopHome = tempDir.resolve("hop-preferred");
+ Files.createDirectories(hopHome.resolve("plugins"));
+ MarketplaceConfig config = new MarketplaceConfig();
+ config.getRepositories().clear();
+ config
+ .getRepositories()
+ .add(
+ new MarketplaceRepository(
+ "corporate-repo", "http://127.0.0.1:" + port + "/missing/",
false));
+ config.getRepositories().add(new MarketplaceRepository("central",
localUrl(port), true));
+
+ InstallReceipt receipt =
+ new PluginInstaller(new LogChannel("test"), hopHome, config)
+ .install(
+ new MavenCoordinates("org.apache.hop", "hop-test-plugin",
"1.0.0"),
+ true,
+ null,
+ "corporate-repo");
+ assertEquals("central", receipt.getRepositoryId());
+ } finally {
+ server.stop(0);
+ }
+ }
+
+ @Test
+ void forcedRepositoryDoesNotFallBack() throws Exception {
+ byte[] zipBytes = buildPluginZip();
+ HttpServer server = zipServer(zipBytes);
+ server.start();
+ try {
+ int port = server.getAddress().getPort();
+ Path hopHome = tempDir.resolve("hop-forced");
+ Files.createDirectories(hopHome.resolve("plugins"));
+ MarketplaceConfig config = new MarketplaceConfig();
+ config.getRepositories().clear();
+ config
+ .getRepositories()
+ .add(new MarketplaceRepository("only", "http://127.0.0.1:" + port +
"/missing/", true));
+ config.getRepositories().add(new MarketplaceRepository("other",
localUrl(port), false));
+
+ assertThrows(
+ HopException.class,
+ () ->
+ new PluginInstaller(new LogChannel("test"), hopHome, config)
+ .install(
+ new MavenCoordinates("org.apache.hop",
"hop-test-plugin", "1.0.0"),
+ true,
+ "only",
+ null));
+
assertFalse(Files.exists(hopHome.resolve("plugins/tech/test/plugin.jar")));
+ } finally {
+ server.stop(0);
+ }
+ }
+
+ @Test
+ void installCommandWithRepoUrlUsesBasicAuthAndRejectsAnonymous() throws
Exception {
+ byte[] zipBytes = buildPluginZip();
+ AtomicInteger anonymous = new AtomicInteger();
+ HttpServer server = HttpServer.create(new InetSocketAddress(0), 0);
+ String path =
"/org/apache/hop/hop-test-plugin/1.0.0/hop-test-plugin-1.0.0.zip";
+ String expectedBasic =
+ "Basic "
+ +
Base64.getEncoder().encodeToString("admin:s3cret".getBytes(StandardCharsets.UTF_8));
+ server.createContext(
+ path,
+ exchange -> {
+ String auth = exchange.getRequestHeaders().getFirst("Authorization");
+ if (!expectedBasic.equals(auth)) {
+ anonymous.incrementAndGet();
+ exchange.sendResponseHeaders(401, -1);
+ exchange.close();
+ return;
+ }
+ exchange.getResponseHeaders().add("Content-Type", "application/zip");
+ exchange.sendResponseHeaders(200, zipBytes.length);
+ exchange.getResponseBody().write(zipBytes);
+ exchange.close();
+ });
+ server.start();
+ try {
+ int port = server.getAddress().getPort();
+ Path hopHome = tempDir.resolve("hop-basic-url");
+ Files.createDirectories(hopHome.resolve("plugins"));
+ String originalUserDir = System.getProperty("user.dir");
+ try {
+ System.setProperty("user.dir", hopHome.toAbsolutePath().toString());
+ withIsolatedMarketplaceConfig(
+ () -> {
+ marketplaceEnv(Map.of());
+ MarketplaceCommand.InstallCommand anonymousCmd =
+ new MarketplaceCommand.InstallCommand();
+ CommandLine anonymousLine = new CommandLine(anonymousCmd);
+ anonymousLine.parseArgs(
+ "--repo-url", localUrl(port),
"org.apache.hop:hop-test-plugin:1.0.0");
+ PrintStream originalErr = System.err;
+ ByteArrayOutputStream err = new ByteArrayOutputStream();
+ System.setErr(new PrintStream(err, true,
StandardCharsets.UTF_8));
+ try {
+ CommandLine.ExecutionException failure =
+ assertThrows(CommandLine.ExecutionException.class,
anonymousCmd::run);
+ assertTrue(failure.getMessage().contains("hop-test-plugin"),
failure.getMessage());
+ } finally {
+ System.setErr(originalErr);
+ }
+ assertTrue(
+ err.toString(StandardCharsets.UTF_8).contains("401"),
+ err.toString(StandardCharsets.UTF_8));
+
assertFalse(Files.exists(hopHome.resolve("plugins/tech/test/plugin.jar")));
+ assertTrue(anonymous.get() > 0);
+
+ marketplaceEnv(
+ Map.of(
+ "HOP_MARKETPLACE_CORPORATE_REPO_USERNAME",
+ "admin",
+ "HOP_MARKETPLACE_CORPORATE_REPO_PASSWORD",
+ "s3cret"));
+ MarketplaceCommand.InstallCommand cmd = new
MarketplaceCommand.InstallCommand();
+ CommandLine cl = new CommandLine(cmd);
+ cl.parseArgs(
+ "--repo-url",
+ localUrl(port),
+ "--repo-id",
+ "corporate-repo",
+ "--auth-type",
+ "basic",
+ "org.apache.hop:hop-test-plugin:1.0.0");
+ cmd.run();
+ });
+
assertTrue(Files.isRegularFile(hopHome.resolve("plugins/tech/test/plugin.jar")));
+ assertEquals(
+ "corporate-repo",
+ PluginInstaller.readReceipt(hopHome,
"hop-test-plugin").getRepositoryId());
+ } finally {
+ MarketplaceRepository.setEnvironmentForTesting(null);
+ if (originalUserDir != null) {
+ System.setProperty("user.dir", originalUserDir);
+ }
+ }
+ } finally {
+ server.stop(0);
+ }
+ }
+
+ @Test
+ void installCommandWithRepoUrlUsesBearerToken() throws Exception {
+ byte[] zipBytes = buildPluginZip();
+ HttpServer server = HttpServer.create(new InetSocketAddress(0), 0);
+ String path =
"/org/apache/hop/hop-test-plugin/1.0.0/hop-test-plugin-1.0.0.zip";
+ server.createContext(
+ path,
+ exchange -> {
+ String auth = exchange.getRequestHeaders().getFirst("Authorization");
+ if (!"Bearer s3cret-token".equals(auth)) {
+ exchange.sendResponseHeaders(401, -1);
+ exchange.close();
+ return;
+ }
+ exchange.getResponseHeaders().add("Content-Type", "application/zip");
+ exchange.sendResponseHeaders(200, zipBytes.length);
+ exchange.getResponseBody().write(zipBytes);
+ exchange.close();
+ });
+ server.start();
+ try {
+ int port = server.getAddress().getPort();
+ Path hopHome = tempDir.resolve("hop-token-url");
+ Files.createDirectories(hopHome.resolve("plugins"));
+ String originalUserDir = System.getProperty("user.dir");
+ try {
+ System.setProperty("user.dir", hopHome.toAbsolutePath().toString());
+ withIsolatedMarketplaceConfig(
+ () -> {
+ marketplaceEnv(Map.of("HOP_MARKETPLACE_CORPORATE_REPO_TOKEN",
"s3cret-token"));
+ MarketplaceCommand.InstallCommand cmd = new
MarketplaceCommand.InstallCommand();
+ CommandLine cl = new CommandLine(cmd);
+ cl.parseArgs(
+ "--repo-url",
+ localUrl(port),
+ "--repo-id",
+ "corporate-repo",
+ "--auth-type",
+ "token",
+ "org.apache.hop:hop-test-plugin:1.0.0");
+ cmd.run();
+ });
+
assertTrue(Files.isRegularFile(hopHome.resolve("plugins/tech/test/plugin.jar")));
+ } finally {
+ MarketplaceRepository.setEnvironmentForTesting(null);
+ if (originalUserDir != null) {
+ System.setProperty("user.dir", originalUserDir);
+ }
+ }
+ } finally {
+ server.stop(0);
+ }
+ }
+
+ @Test
+ void installCommandRejectsShortNameWhenRepositoryCannotBeBrowsed() throws
Exception {
+ Path hopHome = tempDir.resolve("hop-short-name");
+ Files.createDirectories(hopHome.resolve("plugins"));
+ String originalUserDir = System.getProperty("user.dir");
+ try {
+ System.setProperty("user.dir", hopHome.toAbsolutePath().toString());
+ withIsolatedMarketplaceConfig(
+ () -> {
+ marketplaceEnv(Map.of());
+ MarketplaceCommand.InstallCommand cmd = new
MarketplaceCommand.InstallCommand();
+ CommandLine cl = new CommandLine(cmd);
+ cl.parseArgs(
+ "--repo-url",
+ "http://127.0.0.1:9/maven/releases/",
+ "--repo-type",
+ "maven",
+ "zz-no-such-plugin-8723:1.0.0");
+ CommandLine.ExecutionException failure =
+ assertThrows(CommandLine.ExecutionException.class, cmd::run);
+ assertTrue(failure.getMessage().contains("cannot be browsed"),
failure.getMessage());
+ assertTrue(
+ failure.getMessage().contains("groupId:artifactId:version"),
failure.getMessage());
+ });
+ } finally {
+ MarketplaceRepository.setEnvironmentForTesting(null);
+ if (originalUserDir != null) {
+ System.setProperty("user.dir", originalUserDir);
+ }
+ }
+ }
+
+ @Test
+ void installCommandWritesIntoConfiguredPluginFolder() throws Exception {
+ byte[] zipBytes = buildPluginZip();
+ HttpServer server = zipServer(zipBytes);
+ server.start();
+ try {
+ int port = server.getAddress().getPort();
+ Path image = tempDir.resolve("image-home");
+ Path volume = tempDir.resolve("volume-home");
+ Files.createDirectories(image.resolve("plugins"));
+ Files.createDirectories(volume.resolve("plugins"));
+ String originalUserDir = System.getProperty("user.dir");
+ String originalFolders = System.getProperty("HOP_PLUGIN_BASE_FOLDERS");
+ try {
+ System.setProperty("user.dir", image.toAbsolutePath().toString());
+ System.setProperty(
+ "HOP_PLUGIN_BASE_FOLDERS", image.resolve("plugins") + "," +
volume.resolve("plugins"));
+ withIsolatedMarketplaceConfig(
+ () -> {
+ MarketplaceCommand.InstallCommand cmd = new
MarketplaceCommand.InstallCommand();
+ CommandLine cl = new CommandLine(cmd);
+ cl.parseArgs("--repo-url", localUrl(port),
"org.apache.hop:hop-test-plugin:1.0.0");
+ cmd.run();
+ });
+
assertTrue(Files.isRegularFile(volume.resolve("plugins/tech/test/plugin.jar")));
+ assertTrue(Files.isRegularFile(volume.resolve("lib/core/shared.jar")));
+
assertFalse(Files.exists(image.resolve("plugins/tech/test/plugin.jar")));
+ } finally {
+ if (originalUserDir != null) {
+ System.setProperty("user.dir", originalUserDir);
+ }
+ if (originalFolders == null) {
+ System.clearProperty("HOP_PLUGIN_BASE_FOLDERS");
+ } else {
+ System.setProperty("HOP_PLUGIN_BASE_FOLDERS", originalFolders);
+ }
+ }
+ } finally {
+ server.stop(0);
+ }
+ }
+
+ @Test
+ void webLayoutInstallsSharedJarIntoWebInfLibAndSkipsWhenItRemains() throws
Exception {
+ byte[] zipBytes = buildPluginZip();
+ AtomicInteger hits = new AtomicInteger();
+ HttpServer server = zipServer(zipBytes, hits);
+ server.start();
+ try {
+ Path hopHome = tempDir.resolve("tomcat");
+ Files.createDirectories(hopHome.resolve("plugins"));
+ Path webLib = hopHome.resolve("webapps/ROOT/WEB-INF/lib");
+ Files.createDirectories(webLib);
+ MarketplaceConfig config =
localRepoConfig(server.getAddress().getPort());
+ PluginInstaller installer = new PluginInstaller(new LogChannel("test"),
hopHome, config);
+ MavenCoordinates coords = new MavenCoordinates("org.apache.hop",
"hop-test-plugin", "1.0.0");
+
+ installer.install(coords, true);
+ assertTrue(Files.isRegularFile(webLib.resolve("shared.jar")));
+ assertEquals("shared-lib",
Files.readString(webLib.resolve("shared.jar")));
+ assertFalse(Files.exists(hopHome.resolve("lib/core/shared.jar")));
+
assertTrue(Files.isRegularFile(hopHome.resolve("plugins/tech/test/plugin.jar")));
+
+ installer.install(coords, true);
+ assertEquals(1, hits.get(), "a shared jar in WEB-INF/lib still satisfies
the receipt");
+ } finally {
+ server.stop(0);
+ }
+ }
+
+ private static void marketplaceEnv(Map<String, String> values) {
+ MarketplaceRepository.setEnvironmentForTesting(values::get);
+ }
+
+ private static void withIsolatedMarketplaceConfig(ThrowingRunnable action)
throws Exception {
+ HopConfig hopConfig = HopConfig.getInstance();
+ Field field = ConfigFile.class.getDeclaredField("configMap");
+ field.setAccessible(true);
+ @SuppressWarnings("unchecked")
+ Map<String, Object> map = (Map<String, Object>) field.get(hopConfig);
+ Object previous = map.get(MarketplaceConfig.CONFIG_KEY);
+ Map<String, Object> closed = new LinkedHashMap<>();
+ closed.put("id", "closed");
+ closed.put("url", "http://127.0.0.1:1/");
+ closed.put("enabled", true);
+ closed.put("primary", true);
+ closed.put("browse", false);
+ Map<String, Object> marketplace = new LinkedHashMap<>();
+ marketplace.put("enabled", true);
+ marketplace.put("groupId", "org.apache.hop");
+ marketplace.put("repositories", List.of(closed));
+ map.put(MarketplaceConfig.CONFIG_KEY, marketplace);
+ try {
+ action.run();
+ } finally {
+ if (previous == null) {
+ map.remove(MarketplaceConfig.CONFIG_KEY);
+ } else {
+ map.put(MarketplaceConfig.CONFIG_KEY, previous);
+ }
+ }
+ }
+
+ @FunctionalInterface
+ private interface ThrowingRunnable {
+ void run() throws Exception;
+ }
+
/** Captures the phase sequence and byte counts an install reports. */
private static class RecordingInstallListener implements IInstallListener {
private final List<Phase> phases = new ArrayList<>();
@@ -431,10 +849,17 @@ class PluginInstallerTest {
}
private static HttpServer zipServer(byte[] zipBytes) throws IOException {
+ return zipServer(zipBytes, null);
+ }
+
+ private static HttpServer zipServer(byte[] zipBytes, AtomicInteger hits)
throws IOException {
HttpServer server = HttpServer.create(new InetSocketAddress(0), 0);
server.createContext(
"/org/apache/hop/hop-test-plugin/1.0.0/hop-test-plugin-1.0.0.zip",
exchange -> {
+ if (hits != null) {
+ hits.incrementAndGet();
+ }
exchange.getResponseHeaders().add("Content-Type", "application/zip");
exchange.sendResponseHeaders(200, zipBytes.length);
exchange.getResponseBody().write(zipBytes);