diff --git a/.gitignore b/.gitignore
index 963dfac1..04ea336a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,6 +10,9 @@
.project
*.prefs
+# Idea-specific stuff
+.idea/
+*.iml
# jenkins data directory, build dir and release settings
work
diff --git a/.mvn/extensions.xml b/.mvn/extensions.xml
new file mode 100644
index 00000000..1f363640
--- /dev/null
+++ b/.mvn/extensions.xml
@@ -0,0 +1,7 @@
+
+2. [Job setup options](README_JobConfiguration.md)
+3. [Pipeline setup options](README_PipelineConfiguration.md)
diff --git a/README_JobConfiguration.md b/README_JobConfiguration.md
new file mode 100644
index 00000000..625abb21
--- /dev/null
+++ b/README_JobConfiguration.md
@@ -0,0 +1,19 @@
+# Job setup options
+
+Select `Build` > `Add build step` > `Trigger a remote parameterized job`
+
+
+
+You can select a globally configured remote server and only specify a job name here.
+The full URL is calculated based on the remote server, the authentication is taken from the global configuration.
+However it is possible to override the Jenkins base URL (or set the full Job URL) and override credentials used for authentication.
+
+
+
+You can also specify the full job URL and use only the authentication from the global configuration or specify the authentication per job.
+
+
+
+
+# Support of Folders on Remote Jenkins
+[See here for more information](README_PipelineConfiguration.md#user-content-folders)
diff --git a/README_PipelineConfiguration.md b/README_PipelineConfiguration.md
new file mode 100644
index 00000000..a915c6d5
--- /dev/null
+++ b/README_PipelineConfiguration.md
@@ -0,0 +1,190 @@
+# Pipeline setup options
+
+- [Defaults](#user-content-defaults)
+- [Remote Server Configuration](#user-content-server)
+- [Authentication](#user-content-authentication)
+- [The Handle Object](#user-content-handle)
+- [Blocking vs. Non-Blocking](#user-content-blockingnonblocking)
+ - [Blocking usage (recommended)](#user-content-blocking)
+ - [Non-blocking usage](#user-content-nonblocking)
+- [Support of Folders on Remote Jenkins](#user-content-folders)
+
+The `triggerRemoteJob` pipeline step triggers a job on a remote Jenkins. This command is also available in the Jenkins Pipeline Syntax Generator:
+
+You can select a globally configured remote server and only specify a job name here.
+The full URL is calculated based on the remote server, the authentication is taken from the global configuration.
+However it is possible to override the Jenkins base URL (or set the full Job URL) and override credentials used for authentication.
+
+
+
+You can also specify the full job URL and use only the authentication from the global configuration or specify the authentication per job.
+
+
+
+
+
- * If you don't want fields to be persisted, use transient.
-v */
- private CopyOnWriteList
+ * If you don't want fields to be persisted, use transient.
+ */
+ private CopyOnWriteListremoteJenkinsName and the globally
+ * configured remote host, remoteJenkinsURL which overrides the
+ * address locally or job which can be a full job URL.
+ *
+ * @param context the context of this Builder/BuildStep.
+ * @return {@link RemoteJenkinsServer} a RemoteJenkinsServer object, never null.
+ * @throws AbortException if no server found and remoteJenkinsUrl empty.
+ * @throws MalformedURLException if remoteJenkinsName no valid URL
+ * or job an URL but nor valid.
+ */
+ @NonNull
+ public RemoteJenkinsServer evaluateEffectiveRemoteHost(BasicBuildContext context) throws IOException {
+ RemoteJenkinsServer globallyConfiguredServer = findRemoteHost(this.remoteJenkinsName);
+ RemoteJenkinsServer server = globallyConfiguredServer;
+ String expandedJob = getJobExpanded(context);
+ boolean isJobEmpty = isEmpty(trimToNull(expandedJob));
+ boolean isJobUrl = FormValidationUtils.isURL(expandedJob);
+ boolean isRemoteUrlEmpty = isEmpty(trimToNull(this.remoteJenkinsUrl));
+ boolean isRemoteUrlSet = FormValidationUtils.isURL(this.remoteJenkinsUrl);
+ boolean isRemoteNameEmpty = isEmpty(trimToNull(this.remoteJenkinsName));
+
+ if (isJobEmpty)
+ throw new AbortException("Parameter 'Remote Job Name or URL' ('job' variable in Pipeline) not specified.");
+ if (!isRemoteUrlEmpty && !isRemoteUrlSet)
+ throw new AbortException(String.format(
+ "The 'Override remote host URL' parameter value (remoteJenkinsUrl: '%s') is no valid URL",
+ this.remoteJenkinsUrl));
+
+ if (isJobUrl) {
+ // Full job URL configured - get remote Jenkins root URL from there
+ if (server == null)
+ server = new RemoteJenkinsServer();
+ server.setAddress(getRootUrlFromJobUrl(expandedJob));
+
+ } else if (isRemoteUrlSet) {
+ // Remote Jenkins root URL overridden locally in Job/Pipeline
+ if (server == null)
+ server = new RemoteJenkinsServer();
+ server.setAddress(this.remoteJenkinsUrl);
+
+ }
+
+ if (server == null) {
+ if (!isJobUrl) {
+ if (!isRemoteUrlSet && isRemoteNameEmpty)
+ throw new AbortException("Configuration of the remote Jenkins host is missing.");
+ if (!isRemoteUrlSet && !isRemoteNameEmpty && globallyConfiguredServer == null)
+ throw new AbortException(String.format(
+ "Could get remote host with ID '%s' configured in Jenkins global configuration. Please check your global configuration.",
+ this.remoteJenkinsName));
+ }
+ // Generic error message
+ throw new AbortException(String.format(
+ "Could not identify remote host - neither via 'Remote Job Name or URL' (job:'%s'), globally configured"
+ + " remote host (remoteJenkinsName:'%s') nor 'Override remote host URL' (remoteJenkinsUrl:'%s').",
+ expandedJob, this.remoteJenkinsName, this.remoteJenkinsUrl));
+ }
+
+ String addr = server.getAddress();
+ if (addr != null) {
+ URL url = new URL(addr);
+ Semaphore s = hostLocks.get(url.getHost());
+ Integer lastPermit = hostPermits.get(url.getHost());
+ int maxConn = getMaxConn();
+ if (s == null || lastPermit == null || maxConn != lastPermit) {
+ s = new Semaphore(maxConn);
+ hostLocks.put(url.getHost(), s);
+ hostPermits.put(url.getHost(), maxConn);
+ }
+ }
+
+ if (this.overrideTrustAllCertificates) {
+ server.setTrustAllCertificates(this.trustAllCertificates);
+ }
+
+ return server;
+ }
+
+ public Semaphore getLock(String addr) {
+ Semaphore s = null;
+ try {
+ URL url = new URL(addr);
+ s = hostLocks.get(url.getHost());
+ } catch (Exception e) {
+ logger.log(Level.WARNING, "Failed to setup resource lock", e);
+ }
+ return s;
+ }
+
+ /**
+ * Lookup up the globally configured Remote Jenkins Server based on display name
+ *
+ * @param displayName Name of the configuration you are looking for
+ * @return A deep-copy of the RemoteJenkinsServer object configured globally
+ */
+ public @Nullable @CheckForNull RemoteJenkinsServer findRemoteHost(String displayName) {
+ if (isEmpty(displayName))
+ return null;
+ RemoteJenkinsServer server = null;
+ for (RemoteJenkinsServer host : this.getDescriptor().remoteSites) {
+ // if we find a match, then stop looping
+ if (displayName.equals(host.getDisplayName())) {
+ try {
+ server = host.clone();
+ break;
+ } catch (CloneNotSupportedException e) {
+ // Clone is supported by RemoteJenkinsServer
+ throw new RuntimeException(e);
+ }
+ }
+ }
+ return server;
+ }
+
+ protected static String removeTrailingSlashes(String string) {
+ if (isEmpty(string))
+ return string;
+ string = string.trim();
+ while (string.endsWith("/"))
+ string = string.substring(0, string.length() - 1);
+ return string;
+ }
+
+ protected static String removeQueryParameters(String string) {
+ if (isEmpty(string))
+ return string;
+ string = string.trim();
+ int idx = string.indexOf("?");
+ if (idx > 0)
+ string = string.substring(0, idx);
+ return string;
+ }
+
+ protected static String removeHashParameters(String string) {
+ if (isEmpty(string))
+ return string;
+ string = string.trim();
+ int idx = string.indexOf("#");
+ if (idx > 0)
+ string = string.substring(0, idx);
+ return string;
+ }
+
+ private String getRootUrlFromJobUrl(String jobUrl) throws MalformedURLException {
+ if (isEmpty(jobUrl))
+ return null;
+ if (FormValidationUtils.isURL(jobUrl)) {
+ int index;
+ if (jobUrl.contains("/view/")) {
+ index = min(jobUrl.indexOf("/view/"), jobUrl.indexOf("/job/"));
+ } else {
+ index = jobUrl.indexOf("/job/");
+ }
+ if (index < 0)
+ throw new MalformedURLException("Expected '/job/' element in job URL but was: " + jobUrl);
+ return jobUrl.substring(0, index);
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * Convenience function to mark the build as failed. It's intended to only be
+ * called from this.perform().
+ *
+ * @param e exception that caused the build to fail.
+ * @param logger build listener.
+ * @throws IOException if the build fails and shouldNotFailBuild is
+ * not set.
+ */
+ protected void failBuild(Exception e, PrintStream logger) throws IOException {
+ StringBuilder msg = new StringBuilder();
+ if (e instanceof InterruptedException) {
+ Thread current = Thread.currentThread();
+ msg.append(String.format("[Thread: %s/%s]: ", current.getId(), current.getName()));
+ }
+ msg.append(String.format("Remote build failed with '%s' for the following reason: '%s'.%s",
+ e.getClass().getSimpleName(), e.getMessage(),
+ this.getShouldNotFailBuild() ? " But the build will continue." : ""));
+ if (enhancedLogging) {
+ msg.append(NL).append(ExceptionUtils.getFullStackTrace(e));
+ }
+ if (logger != null)
+ logger.println("ERROR: " + msg.toString());
+ if (!this.getShouldNotFailBuild()) {
+ throw new AbortException(e.getClass().getSimpleName() + ": " + e.getMessage());
+ }
+ }
+
+ public void abortRemoteTask(RemoteJenkinsServer remoteServer, Handle handle, BuildContext context)
+ throws IOException, InterruptedException {
+ if (isAbortTriggeredJob() && context != null && handle != null && !handle.isFinished()) {
+ try {
+ if (handle.isQueued()) {
+ RestUtils.cancelQueueItem(remoteServer.getAddress(), handle, context, this);
+ } else {
+ RestUtils.stopRemoteJob(handle, context, this);
+ }
+ } catch (IOException ex) {
+ context.logger.println("Fail to abort remote job: " + ex.getMessage());
+ logger.log(Level.WARNING, "Fail to abort remote job", ex);
+ }
+ }
+ }
+
+ @Override
+ public boolean perform(AbstractBuild, ?> build, Launcher launcher, BuildListener listener)
+ throws InterruptedException, IOException, IllegalArgumentException {
+ FilePath workspace = build.getWorkspace();
+ if (workspace == null)
+ throw new IllegalArgumentException("The workspace can not be null");
+ if (!isStepDisabled(listener.getLogger())) {
+ perform(build, workspace, launcher, listener);
+ }
+ return true;
+ }
+
+ public boolean isStepDisabled(PrintStream printStream) {
+ if (isDisabled()) {
+ printStream.println("remote trigger step was disabled. skipping...");
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Triggers the remote job and, waits until completion if
+ * blockBuildUntilComplete is set.
+ *
+ * @throws InterruptedException if any thread has interrupted the current
+ * thread.
+ * @throws IOException if there is an error retrieving the remote build
+ * data, or, if there is an error retrieving the
+ * remote build status, or, if there is an error
+ * retrieving the console output of the remote
+ * build, or, if the remote build does not succeed.
+ */
+ @Override
+ public void perform(Run, ?> build, FilePath workspace, Launcher launcher, TaskListener listener)
+ throws InterruptedException, IOException {
+ Handle handle = null;
+ BuildContext context = null;
+ RemoteJenkinsServer effectiveRemoteServer = null;
+ try (AutoCloseable ignored = OtelUtils.isOpenTelemetryAvailable() ? OtelUtils.activeSpanIfAvailable(build) : OtelUtils.noop()) {
+ effectiveRemoteServer = evaluateEffectiveRemoteHost(new BasicBuildContext(build, workspace, listener));
+ context = new BuildContext(build, workspace, listener, listener.getLogger(), effectiveRemoteServer);
+ handle = performTriggerAndGetQueueId(context);
+ performWaitForBuild(context, handle);
+ } catch (InterruptedException e) {
+ this.abortRemoteTask(effectiveRemoteServer, handle, context);
+ throw e;
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ /**
+ * Triggers the remote job, identifies the queue ID and, returns a
+ * Handle to this remote execution.
+ *
+ * @param context the context of this Builder/BuildStep.
+ * @return Handle to further tracking of the remote build status.
+ * @throws IOException if there is an error triggering the remote job.
+ * @throws InterruptedException if any thread has interrupted the current
+ * thread.
+ *
+ */
+ public Handle performTriggerAndGetQueueId(@NonNull BuildContext context) throws IOException, InterruptedException {
+ MapblockBuildUntilComplete is set.
+ *
+ * @param context the context of this Builder/BuildStep.
+ * @param handle the handle to the remote execution.
+ * @throws InterruptedException if any thread has interrupted the current
+ * thread.
+ * @throws IOException if any HTTP error or business logic error
+ */
+ public void performWaitForBuild(BuildContext context, Handle handle) throws IOException, InterruptedException {
+ String jobName = handle.getJobName();
+
+ RemoteBuildInfo buildInfo = handle.getBuildInfo();
+ String queueId = buildInfo.getQueueId();
+ if (queueId == null) {
+ throw new AbortException(
+ String.format("Unexpected status: %s. The queue id was not found.", buildInfo.toString()));
+ }
+ context.logger.println(" Remote job queue number: " + buildInfo.getQueueId());
+
+ if (buildInfo.isQueued()) {
+ context.logger.println("Waiting for remote build to be executed...");
+ }
+
+ while (buildInfo.isQueued()) {
+ context.logger.println(
+ "Waiting for " + this.getPollInterval(buildInfo.getStatus()) + " seconds until next poll.");
+ Thread.sleep(this.getPollInterval(buildInfo.getStatus()) * 1000);
+ buildInfo = updateBuildInfo(buildInfo, context);
+ handle.setBuildInfo(buildInfo);
+ }
+
+ URL jobURL = buildInfo.getBuildURL();
+ int jobNumber = buildInfo.getBuildNumber();
+
+ if (jobURL == null || jobNumber == 0) {
+ throw new AbortException(String.format("Unexpected status: %s", buildInfo.toString()));
+ }
+
+ context.logger.println("Remote build started!");
+ context.logger.println(" Remote build URL: " + jobURL);
+ context.logger.println(" Remote build number: " + jobNumber);
+
+ if (context.run != null)
+ RemoteBuildInfoExporterAction.addBuildInfoExporterAction(context.run, jobName, jobNumber, jobURL,
+ buildInfo);
+
+ if (this.getBlockBuildUntilComplete()) {
+ context.logger.println("Blocking local job until remote job completes.");
+
+ buildInfo = updateBuildInfo(buildInfo, context);
+ handle.setBuildInfo(buildInfo);
+
+ if (buildInfo.isRunning()) {
+ context.logger.println("Waiting for remote build to finish ...");
+ }
+
+ String consoleOffset = "0";
+ if (this.getEnhancedLogging()) {
+ context.logger
+ .println("--------------------------------------------------------------------------------");
+ context.logger.println();
+ context.logger.println("Console output of remote job:");
+ consoleOffset = printOffsetConsoleOutput(context, consoleOffset, buildInfo);
+ }
+ while (buildInfo.isRunning()) {
+ if (this.getEnhancedLogging()) {
+ consoleOffset = printOffsetConsoleOutput(context, consoleOffset, buildInfo);
+ } else {
+ context.logger.println(" Waiting for " + this.getPollInterval(buildInfo.getStatus())
+ + " seconds until next poll.");
+ }
+ Thread.sleep(this.getPollInterval(buildInfo.getStatus()) * 1000);
+ buildInfo = updateBuildInfo(buildInfo, context);
+ handle.setBuildInfo(buildInfo);
+ }
+ if (this.getEnhancedLogging()) {
+ consoleOffset = printOffsetConsoleOutput(context, consoleOffset, buildInfo);
+ context.logger
+ .println("--------------------------------------------------------------------------------");
+ }
+
+ context.logger.println("Remote build finished with status " + buildInfo.getResult().toString() + ".");
+ if (context.run != null)
+ RemoteBuildInfoExporterAction.addBuildInfoExporterAction(context.run, jobName, jobNumber, jobURL,
+ buildInfo);
+
+ // If build did not finish with 'success' or 'unstable' then fail build step.
+ if (buildInfo.getResult() != Result.SUCCESS && buildInfo.getResult() != Result.UNSTABLE) {
+ // failBuild will check if the 'shouldNotFailBuild' parameter is set or not, so
+ // will decide how to
+ // handle the failure.
+ this.failBuild(new Exception("The remote job did not succeed."), context.logger);
+ }
+ } else {
+ context.logger.println("Not blocking local job until remote job completes - fire and forget.");
+ }
+ }
+
+ /**
+ * Sends a HTTP request to the API of the remote server requesting a queue item.
+ *
+ * @param queueId the id of the remote job on the queue.
+ * @param context the context of this Builder/BuildStep.
+ * @return {@link QueueItemData} the queue item data.
+ * @throws IOException if there is an error identifying the remote
+ * host, or if there is an error setting the
+ * authorization header, or if the request fails
+ * due to an unknown host, unauthorized
+ * credentials, or another reason, or if there is
+ * an invalid queue response.
+ * @throws InterruptedException if any thread has interrupted the current
+ * thread.
+ */
+ @NonNull
+ private QueueItemData getQueueItemData(@NonNull String queueId, @NonNull BuildContext context)
+ throws IOException, InterruptedException {
+
+ if (context.effectiveRemoteServer.getAddress() == null) {
+ throw new AbortException(
+ "The remote server address can not be empty, or it must be overridden on the job configuration.");
+ }
+ String queueQuery = String.format("%s/queue/item/%s/api/json/", context.effectiveRemoteServer.getAddress(),
+ queueId);
+ ConnectionResponse response = doGet(queueQuery, context, RemoteBuildStatus.QUEUED);
+ JSONObject queueResponse = response.getBody();
+
+ if (queueResponse == null || queueResponse.isNullObject()) {
+ throw new AbortException(String.format("Unexpected queue item response: code %s for request %s",
+ response.getResponseCode(), queueQuery));
+ }
+
+ QueueItemData queueItem = new QueueItemData();
+ queueItem.update(context, queueResponse);
+
+ if (queueItem.isBlocked())
+ context.logger.println(String.format("The remote job is blocked. %s.", queueItem.getWhy()));
+
+ if (queueItem.isPending())
+ context.logger.println(String.format("The remote job is pending. %s.", queueItem.getWhy()));
+
+ if (queueItem.isBuildable())
+ context.logger.println(String.format("The remote job is buildable. %s.", queueItem.getWhy()));
+
+ if (queueItem.isCancelled())
+ throw new AbortException("The remote job was canceled");
+
+ return queueItem;
+ }
+
+ @NonNull
+ public RemoteBuildInfo updateBuildInfo(@NonNull RemoteBuildInfo buildInfo, @NonNull BuildContext context)
+ throws IOException, InterruptedException {
+
+ if (buildInfo.isNotTriggered())
+ return buildInfo;
+
+ if (buildInfo.isQueued()) {
+ String queueId = buildInfo.getQueueId();
+ if (queueId == null) {
+ throw new AbortException(
+ String.format("Unexpected status: %s. The queue id was not found.", buildInfo.toString()));
+ }
+ QueueItemData queueItem = getQueueItemData(queueId, context);
+ if (queueItem.isExecuted()) {
+ URL remoteBuildURL = queueItem.getBuildURL();
+ String effectiveRemoteServerAddress = context.effectiveRemoteServer.getAddress();
+ URL effectiveRemoteBuildURL = generateEffectiveRemoteBuildURL(remoteBuildURL,
+ effectiveRemoteServerAddress);
+ buildInfo.setBuildData(queueItem.getBuildNumber(), effectiveRemoteBuildURL);
+ }
+ return buildInfo;
+ }
+
+ // Only avoid url cache while loop inquiry
+ String buildUrlString = String.format("%sapi/json/?tree=result,building&seed=%d", buildInfo.getBuildURL(),
+ System.currentTimeMillis());
+ JSONObject responseObject = doGet(buildUrlString, context, buildInfo.getStatus()).getBody();
+
+ try {
+ if (responseObject == null
+ || responseObject.getString("result") == null && !responseObject.getBoolean("building")) {
+ return buildInfo;
+ } else if (responseObject.getBoolean("building")) {
+ buildInfo.setBuildStatus(RemoteBuildStatus.RUNNING);
+ } else if (responseObject.getString("result") != null) {
+ buildInfo.setBuildResult(responseObject.getString("result"));
+ } else {
+ context.logger.println("WARNING: Unhandled condition!");
+ }
+ } catch (Exception ignored) {
+ }
+ return buildInfo;
+ }
+
+ protected static URL generateEffectiveRemoteBuildURL(URL remoteBuildURL, String effectiveRemoteServerAddress)
+ throws AbortException {
+ if (effectiveRemoteServerAddress == null || remoteBuildURL == null) {
+ return remoteBuildURL;
+ }
+
+ try {
+ URI effectiveUri = new URI(effectiveRemoteServerAddress);
+ return new URL(effectiveUri.getScheme(), effectiveUri.getHost(), effectiveUri.getPort(),
+ remoteBuildURL.getPath());
+ } catch (URISyntaxException | MalformedURLException ex) {
+ throw new AbortException(String.format("Unexpected syntax error: %s.", ex.toString()));
+ }
+ }
+
+ private String printOffsetConsoleOutput(BuildContext context, String offset, RemoteBuildInfo buildInfo)
+ throws IOException, InterruptedException {
+ if (offset == null || offset.equals("-1")) {
+ return "-1";
+ }
+ String buildUrlString = String.format("%slogText/progressiveText?start=%s", buildInfo.getBuildURL(), offset);
+ ConnectionResponse response = doGet(buildUrlString, context, buildInfo.getStatus());
+
+ String rawBody = response.getRawBody();
+ if (rawBody != null && !rawBody.equals("")) {
+ context.logger.println(rawBody);
+ }
+
+ Mapjob!
+ */
+ public String getRemoteJenkinsUrl() {
+ return trimToNull(remoteJenkinsUrl);
+ }
+
+ public int getHttpGetReadTimeout() {
+ return httpGetReadTimeout;
+ }
+
+ public int getHttpPostReadTimeout() {
+ return httpPostReadTimeout;
+ }
+
+ /**
+ * @return true, if the authorization is overridden in the job configuration,
+ * otherwise false.
+ * @deprecated since 2.3.0-SNAPSHOT - use {@link #getAuth2()} instead.
+ */
+ public boolean getOverrideAuth() {
+ if (auth2 == null)
+ return false;
+ if (auth2 instanceof NullAuth)
+ return false;
+ return true;
+ }
+
+ public Auth2 getAuth2() {
+ return (auth2 != null) ? auth2 : DEFAULT_AUTH;
+ }
+
+ public boolean getShouldNotFailBuild() {
+ return shouldNotFailBuild;
+ }
+
+ public boolean getPreventRemoteBuildQueue() {
+ return preventRemoteBuildQueue;
+ }
+
+ public int getPollInterval(RemoteBuildStatus remoteBuildStatus) {
+ switch (remoteBuildStatus) {
+ case NOT_TRIGGERED:
+ case QUEUED:
+ return QUEUED_ITEMS_POLLINTERVALL;
+ default:
+ return pollInterval;
+ }
+ }
+
+ public boolean getBlockBuildUntilComplete() {
+ return blockBuildUntilComplete;
+ }
+
+ /**
+ * @return the configured job value. Can be a job name or full job
+ * URL.
+ */
+ public String getJob() {
+ return trimToEmpty(job);
+ }
+
+ /**
+ * @return job value with expanded env vars.
+ * @throws IOException if there is an error replacing tokens.
+ */
+ private String getJobExpanded(BasicBuildContext context) throws IOException {
+ return TokenMacroUtils.applyTokenMacroReplacements(getJob(), context);
+ }
+
+ public String getToken() {
+ return trimToEmpty(token);
+ }
+
+ public boolean getEnhancedLogging() {
+ return enhancedLogging;
+ }
+
+ public JobParameters getParameters2() {
+ return (parameters2 != null) ? parameters2 : DEFAULT_PARAMETERS;
+ }
+
+ public int getConnectionRetryLimit() {
+ return connectionRetryLimit; // For now, this is a constant
+ }
+
+ public boolean isDisabled() {
+ return disabled;
+ }
+
+ private @NonNull JSONObject getRemoteJobMetadata(String jobNameOrUrl, @NonNull BuildContext context)
+ throws IOException, InterruptedException {
+
+ String remoteJobUrl = generateJobUrl(context.effectiveRemoteServer, jobNameOrUrl);
+ MapAuthorization header of the connection appropriately. It might also ignore this
+ * step or remove an existing Authorization header.
+ *
+ * @param connection
+ * connection between the application and the remote server.
+ * @param context
+ * the context of this Builder/BuildStep.
+ * @throws IOException
+ * if there is an error generating the authorization header.
+ */
+ public abstract void setAuthorizationHeader(URLConnection connection, BuildContext context) throws IOException;
+
+ /**
+ * Whether a Jenkins crumb is required on {@code POST} requests.
+ */
+ public boolean requiresCrumb() {
+ return true;
+ }
+
+ public abstract String toString();
+
+ /**
+ * Returns a string representing the authorization.
+ *
+ * @param item
+ * the Item (Job, Pipeline,...) we are currently running in.
+ * The item is required to also get Credentials which are defined in the items scope and not Jenkins globally.
+ * Value can be null, but Credentials e.g. configured on a Folder will not be found in this case,
+ * only globally configured Credentials.
+ * @return a string representing the authorization.
+ */
+ public abstract String toString(Item item);
+
+
+ @Override
+ public Auth2 clone() throws CloneNotSupportedException {
+ return (Auth2)super.clone();
+ };
+
+}
diff --git a/src/main/java/org/jenkinsci/plugins/ParameterizedRemoteTrigger/auth2/BearerTokenAuth.java b/src/main/java/org/jenkinsci/plugins/ParameterizedRemoteTrigger/auth2/BearerTokenAuth.java
new file mode 100644
index 00000000..6e1060ee
--- /dev/null
+++ b/src/main/java/org/jenkinsci/plugins/ParameterizedRemoteTrigger/auth2/BearerTokenAuth.java
@@ -0,0 +1,93 @@
+package org.jenkinsci.plugins.ParameterizedRemoteTrigger.auth2;
+
+import java.io.IOException;
+import java.net.URLConnection;
+
+import org.jenkinsci.Symbol;
+import org.jenkinsci.plugins.ParameterizedRemoteTrigger.BuildContext;
+import org.kohsuke.stapler.DataBoundConstructor;
+import org.kohsuke.stapler.DataBoundSetter;
+
+import hudson.Extension;
+import hudson.model.Item;
+import hudson.util.Secret;
+
+
+public class BearerTokenAuth extends Auth2 {
+
+ private static final long serialVersionUID = 3614172320192170597L;
+
+ @Extension
+ public static final Auth2Descriptor DESCRIPTOR = new BearerTokenAuthDescriptor();
+
+ private Secret token;
+
+ @DataBoundConstructor
+ public BearerTokenAuth() {
+ this.token = null;
+ }
+
+ @DataBoundSetter
+ public void setToken(Secret token) {
+ this.token = token;
+ }
+
+ public Secret getToken() {
+ return this.token;
+ }
+
+ @Override
+ public void setAuthorizationHeader(URLConnection connection, BuildContext context) throws IOException {
+ connection.setRequestProperty("Authorization", "Bearer " + getToken().getPlainText());
+ }
+
+ @Override
+ public String toString() {
+ return "'" + getDescriptor().getDisplayName() + "'";
+ }
+
+ @Override
+ public String toString(Item item) {
+ return toString();
+ }
+
+ @Override
+ public Auth2Descriptor getDescriptor() {
+ return DESCRIPTOR;
+ }
+
+ @Symbol("BearerTokenAuth")
+ public static class BearerTokenAuthDescriptor extends Auth2Descriptor {
+ @Override
+ public String getDisplayName() {
+ return "Bearer Token Authentication";
+ }
+ }
+
+ @Override
+ public int hashCode() {
+ final int prime = 31;
+ int result = 1;
+ result = prime * result + ((token == null) ? 0 : token.hashCode());
+ return result;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj)
+ return true;
+ if (obj == null)
+ return false;
+ if (!this.getClass().isInstance(obj))
+ return false;
+ BearerTokenAuth other = (BearerTokenAuth) obj;
+ if (token == null) {
+ if (other.token == null) {
+ return true;
+ } else {
+ return false;
+ }
+ }
+ return token.equals(other.token);
+ }
+}
diff --git a/src/main/java/org/jenkinsci/plugins/ParameterizedRemoteTrigger/auth2/CredentialsAuth.java b/src/main/java/org/jenkinsci/plugins/ParameterizedRemoteTrigger/auth2/CredentialsAuth.java
new file mode 100644
index 00000000..18645a82
--- /dev/null
+++ b/src/main/java/org/jenkinsci/plugins/ParameterizedRemoteTrigger/auth2/CredentialsAuth.java
@@ -0,0 +1,189 @@
+package org.jenkinsci.plugins.ParameterizedRemoteTrigger.auth2;
+
+import static org.jenkinsci.plugins.ParameterizedRemoteTrigger.utils.Base64Utils.AUTHTYPE_BASIC;
+
+import java.io.IOException;
+import java.net.URLConnection;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import org.jenkinsci.Symbol;
+import org.jenkinsci.plugins.ParameterizedRemoteTrigger.BuildContext;
+import org.jenkinsci.plugins.ParameterizedRemoteTrigger.exceptions.CredentialsNotFoundException;
+import org.jenkinsci.plugins.ParameterizedRemoteTrigger.utils.Base64Utils;
+import org.kohsuke.stapler.DataBoundConstructor;
+import org.kohsuke.stapler.DataBoundSetter;
+import org.kohsuke.stapler.Stapler;
+
+import com.cloudbees.plugins.credentials.CredentialsProvider;
+import com.cloudbees.plugins.credentials.common.StandardUsernameCredentials;
+import com.cloudbees.plugins.credentials.common.StandardUsernameListBoxModel;
+import com.cloudbees.plugins.credentials.common.UsernamePasswordCredentials;
+import com.cloudbees.plugins.credentials.domains.DomainRequirement;
+
+import hudson.Extension;
+import hudson.model.Item;
+import hudson.security.ACL;
+import hudson.util.ListBoxModel;
+import jenkins.model.Jenkins;
+
+public class CredentialsAuth extends Auth2 {
+
+ private static final long serialVersionUID = -2650007108928532552L;
+
+ @Extension
+ public static final Auth2Descriptor DESCRIPTOR = new CredentialsAuthDescriptor();
+
+ private String credentials;
+
+ @DataBoundConstructor
+ public CredentialsAuth() {
+ this.credentials = null;
+ }
+
+ @DataBoundSetter
+ public void setCredentials(String credentials) {
+ this.credentials = credentials;
+ }
+
+ public String getCredentials() {
+ return credentials;
+ }
+
+ /**
+ * Tries to find the Jenkins Credential and returns the user name.
+ * @param item the Item (Job, Pipeline,...) we are currently running in.
+ * The item is required to also get Credentials which are defined in the items scope and not Jenkins globally.
+ * Value can be null, but Credentials e.g. configured on a Folder will not be found in this case, only globally configured Credentials.
+ * @return The user name configured in this Credential
+ * @throws CredentialsNotFoundException if credential could not be found.
+ */
+ public String getUserName(Item item) throws CredentialsNotFoundException {
+ UsernamePasswordCredentials creds = _getCredentials(item);
+ return creds.getUsername();
+ }
+
+ /**
+ * Tries to find the Jenkins Credential and returns the password.
+ * @param item the Item (Job, Pipeline,...) we are currently running in.
+ * The item is required to also get Credentials which are defined in the items scope and not Jenkins globally.
+ * Value can be null, but Credentials e.g. configured on a Folder will not be found in this case, only globally configured Credentials.
+ * @return The password configured in this Credential
+ * @throws CredentialsNotFoundException if credential could not be found.
+ */
+ public String getPassword(Item item) throws CredentialsNotFoundException {
+ UsernamePasswordCredentials creds = _getCredentials(item);
+ return creds.getPassword().getPlainText();
+ }
+
+ @Override
+ public String toString() {
+ return toString(null);
+ }
+
+ @Override
+ public String toString(Item item) {
+ try {
+ String userName = getUserName(item);
+ return String.format("'%s' as user '%s' (Credentials ID '%s')", getDescriptor().getDisplayName(), userName, credentials);
+ }
+ catch (CredentialsNotFoundException e) {
+ return String.format("'%s'. WARNING! No credentials found with ID '%s'!", getDescriptor().getDisplayName(), credentials);
+ }
+ }
+
+ /**
+ * Looks up the credentialsID attached to this object in the Global Credentials plugin datastore
+ * @param item the Item (Job, Pipeline,...) we are currently running in.
+ * The item is required to also get Credentials which are defined in the items scope and not Jenkins globally.
+ * Value can be null, but Credentials e.g. configured on a Folder will not be found in this case, only globally configured Credentials.
+ * @return the matched credentials
+ * @throws CredentialsNotFoundException if not found
+ */
+ private UsernamePasswordCredentials _getCredentials(Item item) throws CredentialsNotFoundException {
+ ListPrintStream for writing content to
+ * and a corresponding getContent() method to get the content
+ * which has been written to the PrintStream.
+ *
+ * The reason is from the async Pipeline Handle we don't have
+ * an active TaskListener.getLogger() anymore this means everything
+ * written to the PrintStream (logger) will not be printed to the Pipeline log.
+ * Therefore we provide this PrintStream for logging and the content can be
+ * obtained later via getContent().
+ */
+public class PrintStreamWrapper
+{
+
+ private final ByteArrayOutputStream byteStream;
+ private final PrintStream printStream;
+
+ public PrintStreamWrapper() throws UnsupportedEncodingException {
+ byteStream = new ByteArrayOutputStream();
+ printStream = new PrintStream(byteStream, false, "UTF-8");
+ }
+
+ public PrintStream getPrintStream() {
+ return printStream;
+ }
+
+ /**
+ * Returns all logs since creation and closes the streams.
+ *
+ * @return all logs.
+ * @throws IOException
+ * if UTF-8 charset is not supported.
+ */
+ public String getContent() throws IOException {
+ String string = byteStream.toString("UTF-8");
+ close();
+ return string;
+ }
+
+ /**
+ * Closes the streams.
+ */
+ public void close() {
+ IOUtils.closeQuietly(printStream);
+ IOUtils.closeQuietly(byteStream);
+ }
+
+}
diff --git a/src/main/java/org/jenkinsci/plugins/ParameterizedRemoteTrigger/pipeline/RemoteBuildPipelineStep.java b/src/main/java/org/jenkinsci/plugins/ParameterizedRemoteTrigger/pipeline/RemoteBuildPipelineStep.java
new file mode 100644
index 00000000..2faf660f
--- /dev/null
+++ b/src/main/java/org/jenkinsci/plugins/ParameterizedRemoteTrigger/pipeline/RemoteBuildPipelineStep.java
@@ -0,0 +1,496 @@
+/*
+ * The MIT License
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.jenkinsci.plugins.ParameterizedRemoteTrigger.pipeline;
+
+import static java.util.stream.Collectors.toMap;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Set;
+
+import edu.umd.cs.findbugs.annotations.NonNull;
+
+import org.jenkinsci.plugins.ParameterizedRemoteTrigger.BasicBuildContext;
+import org.jenkinsci.plugins.ParameterizedRemoteTrigger.BuildContext;
+import org.jenkinsci.plugins.ParameterizedRemoteTrigger.RemoteBuildConfiguration;
+import org.jenkinsci.plugins.ParameterizedRemoteTrigger.RemoteJenkinsServer;
+import org.jenkinsci.plugins.ParameterizedRemoteTrigger.auth2.Auth2;
+import org.jenkinsci.plugins.ParameterizedRemoteTrigger.auth2.Auth2.Auth2Descriptor;
+import org.jenkinsci.plugins.ParameterizedRemoteTrigger.auth2.NullAuth;
+import org.jenkinsci.plugins.ParameterizedRemoteTrigger.parameters2.FileParameters;
+import org.jenkinsci.plugins.ParameterizedRemoteTrigger.parameters2.JobParameters;
+import org.jenkinsci.plugins.ParameterizedRemoteTrigger.parameters2.MapParameter;
+import org.jenkinsci.plugins.ParameterizedRemoteTrigger.parameters2.MapParameters;
+import org.jenkinsci.plugins.ParameterizedRemoteTrigger.parameters2.StringParameters;
+import org.jenkinsci.plugins.ParameterizedRemoteTrigger.remoteJob.RemoteBuildStatus;
+import org.jenkinsci.plugins.ParameterizedRemoteTrigger.utils.FormValidationUtils;
+import org.jenkinsci.plugins.ParameterizedRemoteTrigger.utils.FormValidationUtils.AffectedField;
+import org.jenkinsci.plugins.ParameterizedRemoteTrigger.utils.FormValidationUtils.RemoteURLCombinationsResult;
+import org.jenkinsci.plugins.ParameterizedRemoteTrigger.utils.OtelUtils;
+import org.jenkinsci.plugins.structs.describable.UninstantiatedDescribable;
+import org.jenkinsci.plugins.workflow.steps.Step;
+import org.jenkinsci.plugins.workflow.steps.StepContext;
+import org.jenkinsci.plugins.workflow.steps.StepDescriptor;
+import org.jenkinsci.plugins.workflow.steps.StepExecution;
+import org.jenkinsci.plugins.workflow.steps.SynchronousNonBlockingStepExecution;
+import org.kohsuke.accmod.Restricted;
+import org.kohsuke.accmod.restrictions.NoExternalUse;
+import org.kohsuke.stapler.DataBoundConstructor;
+import org.kohsuke.stapler.DataBoundSetter;
+import org.kohsuke.stapler.QueryParameter;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import hudson.AbortException;
+import hudson.Extension;
+import hudson.ExtensionList;
+import hudson.FilePath;
+import hudson.model.Descriptor;
+import hudson.model.Run;
+import hudson.model.TaskListener;
+import hudson.util.FormValidation;
+import hudson.util.ListBoxModel;
+
+public class RemoteBuildPipelineStep extends Step {
+
+ private RemoteBuildConfiguration remoteBuildConfig;
+
+ private static final Logger logger = LoggerFactory.getLogger(RemoteBuildPipelineStep.class);
+
+ @DataBoundConstructor
+ public RemoteBuildPipelineStep(String job) {
+ remoteBuildConfig = new RemoteBuildConfiguration();
+ remoteBuildConfig.setJob(job);
+ remoteBuildConfig.setShouldNotFailBuild(false); // We need to get notified. Failure feedback is collected async
+ // then.
+ remoteBuildConfig.setBlockBuildUntilComplete(true); // default for Pipeline Step
+ }
+
+ @DataBoundSetter
+ public void setAbortTriggeredJob(boolean abortTriggeredJob) {
+ remoteBuildConfig.setAbortTriggeredJob(abortTriggeredJob);
+ }
+
+ @DataBoundSetter
+ public void setMaxConn(int maxConn) {
+ remoteBuildConfig.setMaxConn(maxConn);
+ }
+
+ @DataBoundSetter
+ public void setAuth(Auth2 auth) {
+ remoteBuildConfig.setAuth2(auth);
+ }
+
+ @DataBoundSetter
+ public void setRemoteJenkinsName(String remoteJenkinsName) {
+ remoteBuildConfig.setRemoteJenkinsName(remoteJenkinsName);
+ }
+
+ @DataBoundSetter
+ public void setRemoteJenkinsUrl(String remoteJenkinsUrl) {
+ remoteBuildConfig.setRemoteJenkinsUrl(remoteJenkinsUrl);
+ }
+
+ @DataBoundSetter
+ public void setShouldNotFailBuild(boolean shouldNotFailBuild) {
+ remoteBuildConfig.setShouldNotFailBuild(shouldNotFailBuild);
+ }
+
+ @DataBoundSetter
+ public void setTrustAllCertificates(boolean trustAllCertificates) {
+ remoteBuildConfig.setTrustAllCertificates(trustAllCertificates);
+ }
+
+ @DataBoundSetter
+ public void setOverrideTrustAllCertificates(boolean overrideTrustAllCertificates) {
+ remoteBuildConfig.setOverrideTrustAllCertificates(overrideTrustAllCertificates);
+ }
+
+ @DataBoundSetter
+ public void setPreventRemoteBuildQueue(boolean preventRemoteBuildQueue) {
+ remoteBuildConfig.setPreventRemoteBuildQueue(preventRemoteBuildQueue);
+ }
+
+ @DataBoundSetter
+ public void setHttpGetReadTimeout(int readTimeout) {
+ remoteBuildConfig.setHttpGetReadTimeout(readTimeout);
+ }
+
+ @DataBoundSetter
+ public void setHttpPostReadTimeout(int readTimeout) {
+ remoteBuildConfig.setHttpPostReadTimeout(readTimeout);
+ }
+
+ @DataBoundSetter
+ public void setPollInterval(int pollInterval) {
+ remoteBuildConfig.setPollInterval(pollInterval);
+ }
+
+ @DataBoundSetter
+ public void setBlockBuildUntilComplete(boolean blockBuildUntilComplete) {
+ remoteBuildConfig.setBlockBuildUntilComplete(blockBuildUntilComplete);
+ }
+
+ @DataBoundSetter
+ public void setToken(String token) {
+ remoteBuildConfig.setToken(token);
+ }
+
+ @DataBoundSetter
+ public void setParameters(Object parameters) throws AbortException {
+ if (parameters instanceof JobParameters) {
+ logger.trace("job parameter detected");
+ remoteBuildConfig.setParameters2((JobParameters) parameters);
+ } else if (parameters instanceof String) {
+ final String parametersAsString = (String) parameters;
+ if (parametersAsString.contains("=") || parametersAsString.contains("\n")) {
+ logger.trace("string var");
+ remoteBuildConfig.setParameters2(new StringParameters(parametersAsString));
+ } else {
+ logger.trace("string file");
+ remoteBuildConfig.setParameters2(new FileParameters(parametersAsString));
+ }
+ } else if (parameters instanceof Map) {
+ logger.trace("map");
+
+ @SuppressWarnings("unchecked")
+ final Map