From 4c5ef77ae3c21313e933affe8230629b35987d17 Mon Sep 17 00:00:00 2001 From: Filipe Roque Date: Tue, 30 Dec 2025 15:48:25 +0000 Subject: [PATCH] Improve support for ToolInstallation JENKINS-68052 Improve powershell version selection JENKINS-76152 The powershell path is wrong when using a custom powershell release from a freestyle job --- .../hudson/plugins/powershell/PowerShell.java | 126 +++++++++++++----- .../powershell/PowerShellInstallation.java | 87 ++++++++++-- .../plugins/powershell/Messages.properties | 1 + .../powershell/PowerShell/config.jelly | 10 +- .../PowerShellInstallation/config.jelly | 7 +- src/main/webapp/help.html | 3 +- 6 files changed, 183 insertions(+), 51 deletions(-) create mode 100644 src/main/resources/hudson/plugins/powershell/Messages.properties diff --git a/src/main/java/hudson/plugins/powershell/PowerShell.java b/src/main/java/hudson/plugins/powershell/PowerShell.java index 62cbad9..5acc8eb 100644 --- a/src/main/java/hudson/plugins/powershell/PowerShell.java +++ b/src/main/java/hudson/plugins/powershell/PowerShell.java @@ -1,19 +1,32 @@ package hudson.plugins.powershell; +import edu.umd.cs.findbugs.annotations.NonNull; +import edu.umd.cs.findbugs.annotations.Nullable; import hudson.Extension; import hudson.FilePath; import hudson.Launcher; -import hudson.model.*; +import hudson.Util; +import hudson.model.AbstractBuild; +import hudson.model.AbstractProject; +import hudson.model.BuildListener; +import hudson.model.Computer; +import hudson.model.Item; +import hudson.model.Node; +import hudson.model.TaskListener; import hudson.tasks.BuildStepDescriptor; import hudson.tasks.Builder; import hudson.tasks.CommandInterpreter; +import hudson.util.ListBoxModel; import jenkins.model.Jenkins; import edu.umd.cs.findbugs.annotations.CheckForNull; import org.apache.commons.lang3.SystemUtils; import org.kohsuke.stapler.DataBoundConstructor; import org.kohsuke.stapler.DataBoundSetter; +import org.kohsuke.stapler.verb.POST; import java.io.IOException; +import java.util.ArrayList; +import java.util.List; /** * Invokes PowerShell from Jenkins. @@ -31,6 +44,8 @@ public class PowerShell extends CommandInterpreter { private transient TaskListener listener; + private String installation; + @DataBoundConstructor public PowerShell(String command, boolean stopOnError, boolean useProfile, Integer unstableReturn) { super(command); @@ -43,14 +58,7 @@ public PowerShell(String command, boolean stopOnError, boolean useProfile, Integ public boolean perform(AbstractBuild build, Launcher launcher, BuildListener listener) throws InterruptedException { this.listener = listener; - try - { - return super.perform(build, launcher, listener); - } - catch (InterruptedException e) - { - throw e; - } + return super.perform(build, launcher, listener); } public boolean isStopOnError() { @@ -81,50 +89,87 @@ protected boolean isErrorlevelForUnstableBuild(int exitCode) { return this.unstableReturn != null && exitCode != 0 && this.unstableReturn.equals(exitCode); } + @DataBoundSetter + public void setInstallation(String installation) { + this.installation = Util.fixEmptyAndTrim(installation); + } + + public String getInstallation() { + return installation; + } + + @Override public String[] buildCommandLine(FilePath script) { - String powerShellExecutable = null; - PowerShellInstallation installation = null; - if (isRunningOnWindows(script)) { - installation = Jenkins.get().getDescriptorByType(PowerShellInstallation.DescriptorImpl.class).getAnyInstallation(PowerShellInstallation.DEFAULTWINDOWS); + + final var installation = getPowerShellInstallation(script); + final var powerShellExecutable = getPowerShellExecutable(script, installation); + + List args = new ArrayList<>(); + args.add(powerShellExecutable); + args.add("-NonInteractive"); + if (!useProfile) { + args.add("-NoProfile"); } - else { - installation = Jenkins.get().getDescriptorByType(PowerShellInstallation.DescriptorImpl.class).getAnyInstallation(PowerShellInstallation.DEFAULTLINUX); + if (isRunningOnWindows(script)) { + // ExecutionPolicy option does not work (and is not required) for non-Windows platforms + // See https://github.com/PowerShell/PowerShell/issues/2742 + args.add("-ExecutionPolicy"); + args.add("Bypass"); } + args.add("-File"); + args.add(script.getRemote()); + return args.toArray(new String[0]); + } + + @NonNull + private String getPowerShellExecutable(FilePath script, PowerShellInstallation installation) { + String powerShellExecutable = null; + if (installation != null) { Node node = filePathToNode(script); try { if (node != null && installation.forNode(node, listener) != null) { - powerShellExecutable = installation.forNode(node, listener).getPowerShellBinary(); + installation = installation.forNode(node, listener); } - else { + + final var home = installation.getPowershellHome(); + if (home != null) { + final var separator = isRunningOnWindows(script) ? "\\" : "/"; + powerShellExecutable = home + separator + installation.getPowerShellBinary(); + } else { powerShellExecutable = installation.getPowerShellBinary(); } - } catch (IOException e) { - e.printStackTrace(); - } catch (InterruptedException e) { + } catch (IOException | InterruptedException e) { e.printStackTrace(); } } + + // fallback to installed version on agent if (powerShellExecutable == null) { powerShellExecutable = PowerShellInstallation.getDefaultPowershellWhenNoConfiguration(isRunningOnWindows(script)); } - if (isRunningOnWindows(script)) { - if (useProfile){ - return new String[] { powerShellExecutable, "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", script.getRemote()}; - } else { - return new String[] { powerShellExecutable, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", script.getRemote()}; + return powerShellExecutable; + } + + @Nullable + private PowerShellInstallation getPowerShellInstallation(FilePath script) { + PowerShellInstallation powerShellInstallation; + + final var descriptor = Jenkins.get().getDescriptorByType(PowerShellInstallation.DescriptorImpl.class); + + powerShellInstallation = descriptor.getInstallation(this.installation); + if (powerShellInstallation == null) { + if (isRunningOnWindows(script)) { + powerShellInstallation = descriptor.getAnyInstallation(PowerShellInstallation.DEFAULT_WINDOWS_NAME); } - } else { - // ExecutionPolicy option does not work (and is not required) for non-Windows platforms - // See https://github.com/PowerShell/PowerShell/issues/2742 - if (useProfile){ - return new String[] { powerShellExecutable, "-NonInteractive", "-File", script.getRemote()}; - } else { - return new String[] { powerShellExecutable, "-NonInteractive", "-NoProfile", "-File", script.getRemote()}; + else { + powerShellInstallation = descriptor.getAnyInstallation(PowerShellInstallation.DEFAULT_LINUX_NAME); } } + + return powerShellInstallation; } @Override @@ -181,9 +226,26 @@ public boolean isApplicable(Class jobType) { return true; } + @NonNull @Override public String getDisplayName() { return "PowerShell"; } + + @POST + public ListBoxModel doFillInstallationItems() { + Jenkins.get().checkPermission(Item.CONFIGURE); + + ListBoxModel model = new ListBoxModel(); + + PowerShellInstallation.DescriptorImpl descriptor = + Jenkins.get().getDescriptorByType(PowerShellInstallation.DescriptorImpl.class); + + model.add(Messages.none(), ""); + for (PowerShellInstallation tool : descriptor.getInstallations()) { + model.add(tool.getName()); + } + return model; + } } } diff --git a/src/main/java/hudson/plugins/powershell/PowerShellInstallation.java b/src/main/java/hudson/plugins/powershell/PowerShellInstallation.java index 96d6a53..4776e76 100644 --- a/src/main/java/hudson/plugins/powershell/PowerShellInstallation.java +++ b/src/main/java/hudson/plugins/powershell/PowerShellInstallation.java @@ -4,6 +4,7 @@ import edu.umd.cs.findbugs.annotations.Nullable; import hudson.EnvVars; import hudson.Extension; +import hudson.Util; import hudson.init.InitMilestone; import hudson.init.Initializer; import hudson.model.EnvironmentSpecific; @@ -19,40 +20,60 @@ import org.kohsuke.stapler.StaplerRequest2; import java.io.IOException; +import java.io.Serial; import java.lang.reflect.Array; import java.util.List; public class PowerShellInstallation extends ToolInstallation implements NodeSpecific, EnvironmentSpecific { - public static transient final String DEFAULTWINDOWS = "DefaultWindows"; + static final String DEFAULT_WINDOWS_NAME = "DefaultWindows"; - public static transient final String DEFAULTLINUX = "DefaultLinux"; + static final String DEFAULT_LINUX_NAME = "DefaultLinux"; + private static final String DEFAULT_WINDOWS_EXECUTABLE = "powershell.exe"; + private static final String DEFAULT_LINUX_EXECUTABLE = "pwsh"; + + @Serial private static final long serialVersionUID = 1; + /** + * Originally, {@link hudson.tools.ToolInstallation#home} was the only field used to indicate both installation + * directory and executable file. + *

+ * This was split into two fields, but {@link hudson.tools.ToolInstallation#home} is private with no setter and + * could not be reused, leading to {@link PowerShellInstallation#powershellHome}. + *

+ * In a future version, this field could be migrated back into {@link hudson.tools.ToolInstallation#home}, removing + * {@link PowerShellInstallation#powershellHome}. + */ + private /*almost final*/ String powershellHome; + private /*almost final*/ String executable; + @DataBoundConstructor - public PowerShellInstallation(String name, String home, List> properties) { - super(name, home, properties); + public PowerShellInstallation(String name, String powershellHome, String executable, List> properties) { + super(name, null, properties); + this.powershellHome = Util.fixEmptyAndTrim(powershellHome); + this.executable = executable; } @Override public PowerShellInstallation forNode(@NonNull Node node, TaskListener log) throws IOException, InterruptedException { - return new PowerShellInstallation(getName(), translateFor(node, log), getProperties()); + return new PowerShellInstallation(getName(), translateFor(node, log), executable, getProperties()); } @Override public PowerShellInstallation forEnvironment(EnvVars environment) { - return new PowerShellInstallation(getName(), environment.expand(getHome()), getProperties()); + return new PowerShellInstallation(getName(), environment.expand(getHome()), executable, getProperties()); } public static String getDefaultPowershellWhenNoConfiguration(Boolean isRunningOnWindows) { if (isRunningOnWindows) { - return "powershell.exe"; + return DEFAULT_WINDOWS_EXECUTABLE; } else { - return "pwsh"; + return DEFAULT_LINUX_EXECUTABLE; } } @@ -65,15 +86,55 @@ public static void onLoaded() { return; } - PowerShellInstallation windowsInstallation = new PowerShellInstallation(DEFAULTWINDOWS, "powershell.exe", null); - PowerShellInstallation linuxInstallation = new PowerShellInstallation(DEFAULTLINUX, "pwsh", null); - PowerShellInstallation[] defaultInstallations = { windowsInstallation, linuxInstallation}; - descriptor.setInstallations(defaultInstallations); + PowerShellInstallation windowsInstallation = new PowerShellInstallation(DEFAULT_WINDOWS_NAME, null, DEFAULT_WINDOWS_EXECUTABLE, null); + PowerShellInstallation linuxInstallation = new PowerShellInstallation(DEFAULT_LINUX_NAME, null, DEFAULT_LINUX_EXECUTABLE, null); + descriptor.setInstallations(windowsInstallation, linuxInstallation); descriptor.save(); } + public String getPowershellHome() { + return powershellHome; + } + + @Override + public String getHome() { + return powershellHome; + } + public String getPowerShellBinary() { - return getHome(); + return executable; + } + + public String getExecutable() { + return executable; + } + + @Serial + @Override + protected Object readResolve() { + if (this.executable == null) { + final var home = super.getHome(); + if (home == null) { + this.executable = DEFAULT_LINUX_EXECUTABLE; + this.powershellHome = null; + } else if (DEFAULT_LINUX_EXECUTABLE.equals(home) || DEFAULT_WINDOWS_EXECUTABLE.equals(home)) { + this.executable = home; + this.powershellHome = null; + } else if (home.endsWith(DEFAULT_LINUX_EXECUTABLE)) { + this.executable = DEFAULT_LINUX_EXECUTABLE; + this.powershellHome = home.substring(0, home.length() - DEFAULT_LINUX_EXECUTABLE.length() - 1); + } else if (home.endsWith(DEFAULT_WINDOWS_EXECUTABLE)) { + this.executable = DEFAULT_WINDOWS_EXECUTABLE; + this.powershellHome = home.substring(0, home.length() - DEFAULT_WINDOWS_EXECUTABLE.length() - 1); + } else { + this.executable = DEFAULT_LINUX_EXECUTABLE; + this.powershellHome = home; + } + this.executable = Util.fixEmptyAndTrim(this.executable); + this.powershellHome = Util.fixEmptyAndTrim(this.powershellHome); + } + + return super.readResolve(); } @Extension diff --git a/src/main/resources/hudson/plugins/powershell/Messages.properties b/src/main/resources/hudson/plugins/powershell/Messages.properties new file mode 100644 index 0000000..1578ef9 --- /dev/null +++ b/src/main/resources/hudson/plugins/powershell/Messages.properties @@ -0,0 +1 @@ +none=(None) \ No newline at end of file diff --git a/src/main/resources/hudson/plugins/powershell/PowerShell/config.jelly b/src/main/resources/hudson/plugins/powershell/PowerShell/config.jelly index 9f19d4d..d441215 100644 --- a/src/main/resources/hudson/plugins/powershell/PowerShell/config.jelly +++ b/src/main/resources/hudson/plugins/powershell/PowerShell/config.jelly @@ -27,16 +27,20 @@ THE SOFTWARE. - + - + - + + + + + diff --git a/src/main/resources/hudson/plugins/powershell/PowerShellInstallation/config.jelly b/src/main/resources/hudson/plugins/powershell/PowerShellInstallation/config.jelly index dde0e99..01245c7 100644 --- a/src/main/resources/hudson/plugins/powershell/PowerShellInstallation/config.jelly +++ b/src/main/resources/hudson/plugins/powershell/PowerShellInstallation/config.jelly @@ -4,7 +4,10 @@ - - + + + + + \ No newline at end of file diff --git a/src/main/webapp/help.html b/src/main/webapp/help.html index ad2a5a7..24525e7 100644 --- a/src/main/webapp/help.html +++ b/src/main/webapp/help.html @@ -8,7 +8,8 @@ By default, PowerShell processes profile scripts at startup. This can be disabled to improve startup time, avoid any potential conflict and provide a clean shell. - On Windows it uses PowerShell.exe and on Linux pwsh (PowerShell Core) + The used PowerShell binary is defined by the tool configuration. + (DefaultWindows is used under Windows and DefaultLinux under Linux)

If you already have a batch file in SCM, you can just type in the path of that PowerShell file