diff --git a/src/main/java/io/jenkins/plugins/jfrog/JfStep.java b/src/main/java/io/jenkins/plugins/jfrog/JfStep.java index 9a721ee1..1051658a 100644 --- a/src/main/java/io/jenkins/plugins/jfrog/JfStep.java +++ b/src/main/java/io/jenkins/plugins/jfrog/JfStep.java @@ -23,13 +23,16 @@ import io.jenkins.plugins.jfrog.plugins.PluginsUtils; import jenkins.tasks.SimpleBuildStep; import org.apache.commons.io.FilenameUtils; +import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.exception.ExceptionUtils; import org.jenkinsci.Symbol; import org.jenkinsci.plugins.plaincredentials.StringCredentials; import org.jfrog.build.api.util.Log; +import org.jfrog.build.client.Version; import org.kohsuke.stapler.DataBoundConstructor; import javax.annotation.Nonnull; +import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.nio.charset.StandardCharsets; @@ -46,8 +49,16 @@ @SuppressWarnings("unused") public class JfStep extends Builder implements SimpleBuildStep { private final ObjectMapper mapper = createMapper(); + static final Version MIN_CLI_VERSION_PASSWORD_STDIN = new Version("2.31.3"); static final String STEP_NAME = "jf"; + protected String[] args; + // The JFrog CLI binary path in the agent + protected String jfrogBinaryPath; + // True if the agent's OS is windows + protected boolean isWindows; + // Flag to indicate if the use of password stdin is supported. + protected boolean passwordStdinSupported; @DataBoundConstructor public JfStep(Object args) { @@ -77,11 +88,11 @@ public String[] getArgs() { @Override public void perform(@NonNull Run run, @NonNull FilePath workspace, @NonNull EnvVars env, @NonNull Launcher launcher, @NonNull TaskListener listener) throws InterruptedException, IOException { workspace.mkdirs(); + // Initialize values to be used across the class + initClassValues(workspace, env, launcher); + // Build the 'jf' command ArgumentListBuilder builder = new ArgumentListBuilder(); - boolean isWindows = !launcher.isUnix(); - String jfrogBinaryPath = getJFrogCLIPath(env, isWindows); - builder.add(jfrogBinaryPath).add(args); if (isWindows) { builder = builder.toWindowsCommand(); @@ -89,7 +100,7 @@ public void perform(@NonNull Run run, @NonNull FilePath workspace, @NonNul try (ByteArrayOutputStream taskOutputStream = new ByteArrayOutputStream()) { JfTaskListener jfTaskListener = new JfTaskListener(listener, taskOutputStream); - Launcher.ProcStarter jfLauncher = setupJFrogEnvironment(run, env, launcher, jfTaskListener, workspace, jfrogBinaryPath, isWindows); + Launcher.ProcStarter jfLauncher = setupJFrogEnvironment(run, env, launcher, jfTaskListener, workspace); // Running the 'jf' command int exitValue = jfLauncher.cmds(builder).join(); if (exitValue != 0) { @@ -142,18 +153,16 @@ private void logIfNoToolProvided(EnvVars env, TaskListener listener) { /** * Configure all JFrog relevant environment variables and all servers (if they haven't been configured yet). * - * @param run running as part of a specific build - * @param env environment variables applicable to this step - * @param launcher a way to start processes - * @param listener a place to send output - * @param workspace a workspace to use for any file operations - * @param jfrogBinaryPath path to jfrog cli binary on the filesystem - * @param isWindows is Windows the applicable OS + * @param run running as part of a specific build + * @param env environment variables applicable to this step + * @param launcher a way to start processes + * @param listener a place to send output + * @param workspace a workspace to use for any file operations * @return launcher applicable to this step. * @throws InterruptedException if the step is interrupted * @throws IOException in case of any I/O error, or we failed to run the 'jf' command */ - public Launcher.ProcStarter setupJFrogEnvironment(Run run, EnvVars env, Launcher launcher, TaskListener listener, FilePath workspace, String jfrogBinaryPath, boolean isWindows) throws IOException, InterruptedException { + public Launcher.ProcStarter setupJFrogEnvironment(Run run, EnvVars env, Launcher launcher, TaskListener listener, FilePath workspace) throws IOException, InterruptedException { JFrogCliConfigEncryption jfrogCliConfigEncryption = run.getAction(JFrogCliConfigEncryption.class); if (jfrogCliConfigEncryption == null) { // Set up the config encryption action to allow encrypting the JFrog CLI configuration and make sure we only create one key @@ -166,7 +175,7 @@ public Launcher.ProcStarter setupJFrogEnvironment(Run run, EnvVars env, La // Configure all servers, skip if all server ids have already been configured. if (shouldConfig(jfrogHomeTempDir)) { logIfNoToolProvided(env, listener); - configAllServers(jfLauncher, jfrogBinaryPath, isWindows, run.getParent()); + configAllServers(jfLauncher, run.getParent()); } return jfLauncher; } @@ -190,14 +199,14 @@ private boolean shouldConfig(FilePath jfrogHomeTempDir) throws IOException, Inte /** * Locally configure all servers that was configured in the Jenkins UI. */ - private void configAllServers(Launcher.ProcStarter launcher, String jfrogBinaryPath, boolean isWindows, Job job) throws IOException, InterruptedException { + private void configAllServers(Launcher.ProcStarter launcher, Job job) throws IOException, InterruptedException { // Config all servers using the 'jf c add' command. List jfrogInstances = JFrogPlatformBuilder.getJFrogPlatformInstances(); - if (jfrogInstances != null && jfrogInstances.size() > 0) { + if (jfrogInstances != null && !jfrogInstances.isEmpty()) { for (JFrogPlatformInstance jfrogPlatformInstance : jfrogInstances) { // Build 'jf' command ArgumentListBuilder builder = new ArgumentListBuilder(); - addConfigArguments(builder, jfrogPlatformInstance, jfrogBinaryPath, job); + addConfigArguments(builder, jfrogPlatformInstance, jfrogBinaryPath, job, launcher); if (isWindows) { builder = builder.toWindowsCommand(); } @@ -210,27 +219,48 @@ private void configAllServers(Launcher.ProcStarter launcher, String jfrogBinaryP } } - private void addConfigArguments(ArgumentListBuilder builder, JFrogPlatformInstance jfrogPlatformInstance, String jfrogBinaryPath, Job job) { - String credentialsId = jfrogPlatformInstance.getCredentialsConfig().getCredentialsId(); + private void addConfigArguments(ArgumentListBuilder builder, JFrogPlatformInstance jfrogPlatformInstance, String jfrogBinaryPath, Job job, Launcher.ProcStarter launcher) throws IOException { builder.add(jfrogBinaryPath).add("c").add("add").add(jfrogPlatformInstance.getId()); - // Add credentials + addCredentialsArguments(builder, jfrogPlatformInstance, job, launcher); + addUrlArguments(builder, jfrogPlatformInstance); + builder.add("--interactive=false").add("--overwrite=true"); + } + + void addCredentialsArguments(ArgumentListBuilder builder, JFrogPlatformInstance jfrogPlatformInstance, Job job, Launcher.ProcStarter launcher) throws IOException { + String credentialsId = jfrogPlatformInstance.getCredentialsConfig().getCredentialsId(); StringCredentials accessTokenCredentials = PluginsUtils.accessTokenCredentialsLookup(credentialsId, job); + if (accessTokenCredentials != null) { builder.addMasked("--access-token=" + accessTokenCredentials.getSecret().getPlainText()); } else { Credentials credentials = PluginsUtils.credentialsLookup(credentialsId, job); builder.add("--user=" + credentials.getUsername()); + addPasswordArgument(builder, credentials, launcher); + } + } + + // Provides password input via stdin if supported; otherwise, defaults to --password argument. + // Stdin support requires a minimum CLI version and excludes plugin launchers. + // Plugin launchers may lose stdin input, causing command failure; + // hence, stdin is unsupported without plugin-specific handling. + private void addPasswordArgument(ArgumentListBuilder builder, Credentials credentials, Launcher.ProcStarter launcher) throws IOException { + if (this.passwordStdinSupported) { + // Use stdin + builder.add("--password-stdin"); + try (ByteArrayInputStream inputStream = new ByteArrayInputStream(credentials.getPassword().getPlainText().getBytes(StandardCharsets.UTF_8))) { + launcher.stdin(inputStream); + } + } else { + // Use masked default password argument builder.addMasked("--password=" + credentials.getPassword()); } - // Add URLs + } + + private void addUrlArguments(ArgumentListBuilder builder, JFrogPlatformInstance jfrogPlatformInstance) { builder.add("--url=" + jfrogPlatformInstance.getUrl()); builder.add("--artifactory-url=" + jfrogPlatformInstance.inferArtifactoryUrl()); builder.add("--distribution-url=" + jfrogPlatformInstance.inferDistributionUrl()); builder.add("--xray-url=" + jfrogPlatformInstance.inferXrayUrl()); - - builder.add("--interactive=false"); - // The installation process takes place more than once per build, so we will configure the same server ID several times. - builder.add("--overwrite=true"); } /** @@ -280,6 +310,19 @@ private void logIllegalBuildPublishOutput(Log log, ByteArrayOutputStream taskOut log.warn("Illegal build-publish output: " + taskOutputStream.toString(StandardCharsets.UTF_8)); } + /** + * Initializes values required across the class for running CLI commands. + * + * @param workspace Workspace to use for any file operations. + * @param env Environment variables for this step. + * @param launcher Launcher to start processes. + */ + private void initClassValues(FilePath workspace, EnvVars env, Launcher launcher) throws IOException, InterruptedException { + this.isWindows = !launcher.isUnix(); + this.jfrogBinaryPath = getJFrogCLIPath(env, isWindows); + this.passwordStdinSupported = isPasswordStdinSupported(workspace, env, launcher); + } + @Symbol("jf") @Extension public static final class DescriptorImpl extends BuildStepDescriptor { @@ -294,4 +337,48 @@ public boolean isApplicable(Class jobType) { return true; } } + + Version getJfrogCliVersion(Launcher.ProcStarter launcher) throws IOException, InterruptedException { + try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) { + ArgumentListBuilder builder = new ArgumentListBuilder(); + builder.add(jfrogBinaryPath).add("-v"); + int exitCode = launcher + .cmds(builder) + .pwd(launcher.pwd()) + .stdout(outputStream) + .join(); + if (exitCode != 0) { + throw new IOException("Failed to get JFrog CLI version: " + outputStream.toString(StandardCharsets.UTF_8)); + } + String versionOutput = outputStream.toString(StandardCharsets.UTF_8).trim(); + String version = StringUtils.substringAfterLast(versionOutput, " "); + return new Version(version); + } + } + + /** + * Determines if the password can be securely passed via stdin to the CLI, + * rather than using the --password flag. This depends on two factors: + * 1. The JFrog CLI version on the agent (minimum supported version is 2.31.3). + * 2. Whether the launcher is a custom (plugin) launcher. + *

+ * Note: Plugin-based launchers do not support stdin input handling by default + * and need special handling. + * + * @param workspace The workspace file path. + * @param env The environment variables. + * @param launcher The command launcher. + * @return true if stdin-based password handling is supported; false otherwise. + */ + public boolean isPasswordStdinSupported(FilePath workspace, EnvVars env, Launcher launcher) throws IOException, InterruptedException { + // Determine if the launcher is a plugin (custom) launcher + boolean isPluginLauncher = launcher.getClass().getName().contains("org.jenkinsci.plugins"); + if (isPluginLauncher) { + return false; + } + // Check CLI version + Launcher.ProcStarter procStarter = launcher.launch().envs(env).pwd(workspace); + Version currentCliVersion = getJfrogCliVersion(procStarter); + return currentCliVersion.isAtLeast(MIN_CLI_VERSION_PASSWORD_STDIN); + } } diff --git a/src/test/java/io/jenkins/plugins/jfrog/JfStepTest.java b/src/test/java/io/jenkins/plugins/jfrog/JfStepTest.java index afa4025f..2ea7ca17 100644 --- a/src/test/java/io/jenkins/plugins/jfrog/JfStepTest.java +++ b/src/test/java/io/jenkins/plugins/jfrog/JfStepTest.java @@ -1,19 +1,30 @@ package io.jenkins.plugins.jfrog; import hudson.EnvVars; +import hudson.FilePath; +import hudson.Launcher; +import hudson.model.Job; +import hudson.util.ArgumentListBuilder; +import io.jenkins.plugins.jfrog.configuration.CredentialsConfig; +import io.jenkins.plugins.jfrog.configuration.JFrogPlatformInstance; +import org.jfrog.build.client.Version; import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; +import java.io.ByteArrayOutputStream; +import java.io.IOException; import java.util.stream.Stream; +import static io.jenkins.plugins.jfrog.JfStep.MIN_CLI_VERSION_PASSWORD_STDIN; import static io.jenkins.plugins.jfrog.JfStep.getJFrogCLIPath; import static io.jenkins.plugins.jfrog.JfrogInstallation.JFROG_BINARY_PATH; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; -/** - * @author yahavi - **/ public class JfStepTest { @ParameterizedTest @@ -37,4 +48,90 @@ private static Stream jfrogCLIPathProvider() { Arguments.of(new EnvVars(), true, "jf.exe") ); } -} + + @Test + void getJfrogCliVersionTest() throws IOException, InterruptedException { + // Mock the Launcher + Launcher launcher = mock(Launcher.class); + // Mock the Launcher.ProcStarter + Launcher.ProcStarter procStarter = mock(Launcher.ProcStarter.class); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + // Mocks the return value of --version command + outputStream.write("jf version 2.31.0 ".getBytes()); + // Mock the behavior of the Launcher and ProcStarter + when(launcher.launch()).thenReturn(procStarter); + when(procStarter.cmds(any(ArgumentListBuilder.class))).thenReturn(procStarter); + when(procStarter.pwd((FilePath) any())).thenReturn(procStarter); + when(procStarter.stdout(any(ByteArrayOutputStream.class))).thenAnswer(invocation -> { + ByteArrayOutputStream out = invocation.getArgument(0); + out.write(outputStream.toByteArray()); + return procStarter; + }); + when(procStarter.join()).thenReturn(0); + + // Create an instance of JfStep and call the method + JfStep jfStep = new JfStep("--version"); + jfStep.isWindows = System.getProperty("os.name").toLowerCase().contains("win"); + Version version = jfStep.getJfrogCliVersion(procStarter); + + // Verify the result + assertEquals("2.31.0", version.toString()); + } + + /** + * Tests the addCredentialsArguments method logic with password-stdin vs.-- password flag. + * Password-stdin flag should only be set if the CLI version is supported + * AND the launcher is not the plugin launcher. + * Plugin launchers do not support password-stdin, as they do not have access to the standard input by default. + * + * @param cliVersion The CLI version + * @param isPluginLauncher Whether the launcher is the plugin launcher + * @param expectedOutput The expected output + * @throws IOException error + */ + @ParameterizedTest + @MethodSource("provideTestArguments") + void testAddCredentialsArguments(String cliVersion, boolean isPluginLauncher, String expectedOutput) throws IOException { + // Mock the necessary objects + JFrogPlatformInstance jfrogPlatformInstance = mock(JFrogPlatformInstance.class); + CredentialsConfig credentialsConfig = mock(CredentialsConfig.class); + when(jfrogPlatformInstance.getId()).thenReturn("instance-id"); + when(jfrogPlatformInstance.getCredentialsConfig()).thenReturn(credentialsConfig); + when(credentialsConfig.getCredentialsId()).thenReturn("credentials-id"); + + Job job = mock(Job.class); + Launcher.ProcStarter launcher = mock(Launcher.ProcStarter.class); + + // Create an instance of JfStep + JfStep jfStep = new JfStep("Mock Test"); + // Mock password stdin supported or not. + jfStep.passwordStdinSupported = new Version(cliVersion).isAtLeast(MIN_CLI_VERSION_PASSWORD_STDIN) && !isPluginLauncher; + + // Create an ArgumentListBuilder + ArgumentListBuilder builder = new ArgumentListBuilder(); + + // Call the addCredentialsArguments method + jfStep.addCredentialsArguments(builder, jfrogPlatformInstance, job, launcher); + + // Verify the arguments + assertTrue(builder.toList().contains(expectedOutput)); + } + + private static Stream provideTestArguments() { + String passwordFlag = "--password="; + String passwordStdinFlag = "--password-stdin"; + // Min version for password stdin is 2.31.3 + return Stream.of( + // Supported CLI version but Plugin Launcher + Arguments.of("2.57.0", true, passwordFlag), + // Unsupported Version + Arguments.of("2.31.0", false, passwordFlag), + // Supported CLI version and local launcher + Arguments.of("2.57.0", false, passwordStdinFlag), + // Unsupported CLI version and Plugin Launcher + Arguments.of("2.31.0", true, passwordFlag), + // Minimum supported CLI version for password stdin + Arguments.of("2.31.3", false, passwordStdinFlag) + ); + } +} \ No newline at end of file diff --git a/src/test/java/io/jenkins/plugins/jfrog/integration/PipelineTestBase.java b/src/test/java/io/jenkins/plugins/jfrog/integration/PipelineTestBase.java index 9cba91a9..64746fc6 100644 --- a/src/test/java/io/jenkins/plugins/jfrog/integration/PipelineTestBase.java +++ b/src/test/java/io/jenkins/plugins/jfrog/integration/PipelineTestBase.java @@ -19,7 +19,6 @@ import io.jenkins.plugins.jfrog.BinaryInstaller; import io.jenkins.plugins.jfrog.JfrogInstallation; import io.jenkins.plugins.jfrog.ReleasesInstaller; -import io.jenkins.plugins.jfrog.configuration.Credentials; import io.jenkins.plugins.jfrog.configuration.CredentialsConfig; import io.jenkins.plugins.jfrog.configuration.JFrogPlatformBuilder; import io.jenkins.plugins.jfrog.configuration.JFrogPlatformInstance; @@ -69,6 +68,7 @@ public class PipelineTestBase { static final String JFROG_CLI_TOOL_NAME_1 = "jfrog-cli"; static final String JFROG_CLI_TOOL_NAME_2 = "jfrog-cli-2"; static final String TEST_CONFIGURED_SERVER_ID = "serverId"; + static final String TEST_CONFIGURED_SERVER_ID_2 = "serverId2"; // Set up jenkins and configure latest JFrog CLI. public void initPipelineTest(JenkinsRule jenkins) throws Exception { @@ -161,13 +161,11 @@ private static void verifyEnvironment() { private void setGlobalConfiguration() throws IOException { JFrogPlatformBuilder.DescriptorImpl jfrogBuilder = (JFrogPlatformBuilder.DescriptorImpl) jenkins.getInstance().getDescriptor(JFrogPlatformBuilder.class); Assert.assertNotNull(jfrogBuilder); - CredentialsConfig emptyCred = new CredentialsConfig(StringUtils.EMPTY, Credentials.EMPTY_CREDENTIALS); CredentialsConfig platformCred = new CredentialsConfig(Secret.fromString(ARTIFACTORY_USERNAME), Secret.fromString(ARTIFACTORY_PASSWORD), Secret.fromString(ACCESS_TOKEN), "credentials"); - List artifactoryServers = new ArrayList() {{ - // Dummy server to test multiple configured servers. - // The dummy server should be configured first to ensure the right server is being used (and not the first one). - add(new JFrogPlatformInstance("dummyServerId", "", emptyCred, "", "", "")); + List artifactoryServers = new ArrayList<>() {{ + // Configure multiple servers to test multiple servers. add(new JFrogPlatformInstance(TEST_CONFIGURED_SERVER_ID, PLATFORM_URL, platformCred, ARTIFACTORY_URL, "", "")); + add(new JFrogPlatformInstance(TEST_CONFIGURED_SERVER_ID_2, PLATFORM_URL, platformCred, ARTIFACTORY_URL, "", "")); }}; jfrogBuilder.setJfrogInstances(artifactoryServers); Jenkins.get().getDescriptorByType(JFrogPlatformBuilder.DescriptorImpl.class).setJfrogInstances(artifactoryServers);