From 714df60a6e2b2da9dc7c24c2775d725add135237 Mon Sep 17 00:00:00 2001 From: Bhanu Reddy Date: Mon, 9 Feb 2026 13:06:41 +0530 Subject: [PATCH 01/18] Fix for parallel processing of jfrog cli download --- README.md | 37 ++ .../jfrog/callables/JFrogCliDownloader.java | 448 +++++++++++++++++- 2 files changed, 465 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index f26750bb..e50dccb1 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ - [Automatic installation from release.jfrog.io](#automatic-installation-from-releasejfrogio) - [Automatic installation from Artifactory](#automatic-installation-from-artifactory) - [Manual installation](#manual-installation) + - [CLI Installation Behavior](#cli-installation-behavior) - [Using JFrog CLI in your pipeline jobs](#using-jfrog-cli-in-your-pipeline-jobs) - [Setting the build name and build number](#setting-the-build-name-and-the-build-number) - [Using multiple JFrog Platform instances](#using-multiple-jfrog-platform-instances) @@ -89,6 +90,42 @@ as shown in the below screenshot. +### CLI Installation Behavior + +The plugin uses an intelligent installation strategy that handles various scenarios gracefully: + +#### Fresh Installation +When JFrog CLI is not yet installed on an agent: +- The plugin acquires an installation lock to coordinate parallel pipeline steps +- If another step is already installing, subsequent steps wait (up to 5 minutes) +- This ensures reliable installation even with parallel pipeline execution + +#### Upgrade Behavior +When an existing JFrog CLI installation is detected: +- The plugin checks if an upgrade is available (via SHA256 comparison) +- If an upgrade is needed and the binary is **not in use**, it's replaced with the new version +- If the binary is **currently in use** by another process (common on Windows with parallel steps): + - The upgrade is gracefully skipped + - The existing CLI version is used for the current build + - A warning is logged: *"Upgrade skipped. Using existing version. Upgrade will be attempted in next build."* + - The next build will automatically attempt the upgrade again + +This behavior ensures: +- **Build reliability**: Builds don't fail due to file locking conflicts on Windows +- **Automatic recovery**: Upgrades are deferred, not lost +- **Parallel step safety**: Multiple parallel steps can safely use the CLI without conflicts + +#### Windows Considerations +On Windows agents, file locking can occur when: +- Multiple parallel pipeline steps attempt to install/upgrade the CLI simultaneously +- Antivirus software scans the binary during installation +- The CLI is actively being executed by another step + +The plugin handles these scenarios gracefully by: +1. Using file-based locking for installation coordination +2. Detecting Windows file locking errors +3. Falling back to existing versions when upgrades can't proceed + ## Using JFrog CLI in your pipeline jobs To have your pipeline jobs run JFrog CLI commands, add the following to your pipeline script. diff --git a/src/main/java/io/jenkins/plugins/jfrog/callables/JFrogCliDownloader.java b/src/main/java/io/jenkins/plugins/jfrog/callables/JFrogCliDownloader.java index f63355fd..61383b90 100644 --- a/src/main/java/io/jenkins/plugins/jfrog/callables/JFrogCliDownloader.java +++ b/src/main/java/io/jenkins/plugins/jfrog/callables/JFrogCliDownloader.java @@ -14,10 +14,14 @@ import org.jfrog.build.extractor.clientConfiguration.client.artifactory.ArtifactoryManager; import java.io.File; +import java.io.FileOutputStream; import java.io.IOException; +import java.nio.channels.FileChannel; +import java.nio.channels.FileLock; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.StandardCopyOption; import static org.jfrog.build.client.DownloadResponse.SHA256_HEADER_NAME; @@ -38,6 +42,30 @@ public class JFrogCliDownloader extends MasterToSlaveFileCallable { * decoded "[RELEASE]" for the download url */ private static final String RELEASE = "[RELEASE]"; + + /** + * Lock file name used to coordinate parallel installations on the same agent. + * This is especially important on Windows where file operations are not atomic + * and parallel steps can conflict when trying to install the CLI simultaneously. + */ + private static final String LOCK_FILE_NAME = ".jfrog-cli-install.lock"; + + /** + * Maximum time to wait for acquiring the installation lock (in milliseconds). + * Only used for fresh installations where we must succeed. + */ + private static final long LOCK_TIMEOUT_MS = 300000; + + /** + * Retry interval when waiting for the installation lock (in milliseconds). + */ + private static final long LOCK_RETRY_INTERVAL_MS = 1000; + + /** + * Minimum valid CLI binary size in bytes (1MB). + * Used to determine if an existing CLI installation is valid. + */ + private static final long MIN_VALID_CLI_SIZE = 1024 * 1024; JenkinsProxyConfiguration proxyConfiguration; private String providedVersion; @@ -50,6 +78,218 @@ public class JFrogCliDownloader extends MasterToSlaveFileCallable { public Void invoke(File toolLocation, VirtualChannel channel) throws IOException, InterruptedException { log.getLogger().println("[JFrogCliDownloader] Starting CLI download"); + // Ensure the tool location directory exists + if (!toolLocation.exists()) { + toolLocation.mkdirs(); + } + + // Check if this is a fresh install or an upgrade + File existingCli = new File(toolLocation, binaryName); + boolean isFreshInstall = !isExistingCliValid(existingCli); + + if (isFreshInstall) { + log.getLogger().println("[JFrogCliDownloader] Fresh installation detected - will wait for lock if needed"); + performFreshInstallation(toolLocation); + } else { + log.getLogger().println("[JFrogCliDownloader] Existing CLI found - attempting upgrade"); + performUpgrade(toolLocation, existingCli); + } + + return null; + } + + /** + * Performs a fresh CLI installation with lock waiting. + * For fresh installations, we MUST succeed, so we wait for the lock with timeout. + * + * @param toolLocation The target directory for CLI installation + * @throws IOException If installation fails + * @throws InterruptedException If interrupted during installation + */ + private void performFreshInstallation(File toolLocation) throws IOException, InterruptedException { + File lockFile = new File(toolLocation, LOCK_FILE_NAME); + + log.getLogger().println("[JFrogCliDownloader] Acquiring installation lock: " + lockFile.getAbsolutePath()); + + try (FileOutputStream lockFileStream = new FileOutputStream(lockFile); + FileChannel lockChannel = lockFileStream.getChannel()) { + + // For fresh install, wait for lock with timeout (must succeed) + FileLock lock = acquireLockWithTimeout(lockChannel, lockFile); + + try { + log.getLogger().println("[JFrogCliDownloader] Installation lock acquired, proceeding with fresh installation"); + + // Re-check after acquiring lock - another process might have installed it + File existingCli = new File(toolLocation, binaryName); + if (isExistingCliValid(existingCli)) { + log.getLogger().println("[JFrogCliDownloader] CLI was installed by another process while waiting for lock"); + // Still need to check if version matches + performDownloadWithLock(toolLocation); + } else { + performDownloadWithLock(toolLocation); + } + } finally { + lock.release(); + log.getLogger().println("[JFrogCliDownloader] Installation lock released"); + } + } + } + + /** + * Attempts to upgrade an existing CLI installation. + * For upgrades, if the binary is locked (in use by another process), we gracefully + * skip the upgrade and use the existing version. The upgrade will be attempted + * in the next build. + * + * @param toolLocation The target directory for CLI installation + * @param existingCli The existing CLI binary file + * @throws IOException If upgrade fails for non-recoverable reasons + * @throws InterruptedException If interrupted during upgrade + */ + private void performUpgrade(File toolLocation, File existingCli) throws IOException, InterruptedException { + File lockFile = new File(toolLocation, LOCK_FILE_NAME); + + try (FileOutputStream lockFileStream = new FileOutputStream(lockFile); + FileChannel lockChannel = lockFileStream.getChannel()) { + + // For upgrades, try to acquire lock quickly (non-blocking) + FileLock lock = lockChannel.tryLock(); + + if (lock == null) { + // Lock is held by another process - skip upgrade, use existing + log.getLogger().println("[JFrogCliDownloader] WARNING: Another installation is in progress. " + + "Using existing CLI version. Upgrade will be attempted in next build."); + return; + } + + try { + log.getLogger().println("[JFrogCliDownloader] Lock acquired, attempting upgrade"); + performDownloadWithLockForUpgrade(toolLocation, existingCli); + } finally { + lock.release(); + log.getLogger().println("[JFrogCliDownloader] Installation lock released"); + } + } + } + + /** + * Performs download for upgrade scenario with graceful fallback. + * If the binary is locked during replacement, we skip the upgrade and use existing. + * + * @param toolLocation The target directory + * @param existingCli The existing CLI binary + * @throws IOException If download fails for non-recoverable reasons + * @throws InterruptedException If interrupted + */ + private void performDownloadWithLockForUpgrade(File toolLocation, File existingCli) throws IOException, InterruptedException { + String version = StringUtils.defaultIfBlank(providedVersion, RELEASE); + String cliUrlSuffix = String.format("/%s/v2-jf/%s/jfrog-cli-%s/%s", repository, version, OsUtils.getOsDetails(), binaryName); + + JenkinsBuildInfoLog buildInfoLog = new JenkinsBuildInfoLog(log); + String artifactoryUrl = instance.inferArtifactoryUrl(); + + try (ArtifactoryManager manager = new ArtifactoryManager(artifactoryUrl, + Secret.toString(instance.getCredentialsConfig().getUsername()), + Secret.toString(instance.getCredentialsConfig().getPassword()), + Secret.toString(instance.getCredentialsConfig().getAccessToken()), buildInfoLog)) { + + if (proxyConfiguration.isProxyConfigured(artifactoryUrl)) { + manager.setProxyConfiguration(proxyConfiguration); + } + + String artifactorySha256 = getArtifactSha256(manager, cliUrlSuffix); + + if (!shouldDownloadTool(toolLocation, artifactorySha256)) { + log.getLogger().println("[JFrogCliDownloader] CLI is up-to-date, skipping download"); + return; + } + + if (version.equals(RELEASE)) { + log.getLogger().printf("[JFrogCliDownloader] Upgrading '%s' to latest version from: %s%n", + binaryName, artifactoryUrl + cliUrlSuffix); + } else { + log.getLogger().printf("[JFrogCliDownloader] Upgrading '%s' to version %s from: %s%n", + binaryName, version, artifactoryUrl + cliUrlSuffix); + } + + // Attempt upgrade with graceful fallback + boolean upgradeSucceeded = performAtomicDownloadForUpgrade(manager, cliUrlSuffix, toolLocation, + artifactorySha256, existingCli); + + if (upgradeSucceeded) { + log.getLogger().println("[JFrogCliDownloader] Upgrade completed successfully"); + } else { + log.getLogger().println("[JFrogCliDownloader] Upgrade skipped, using existing CLI version"); + } + } + } + + /** + * Checks if an existing CLI binary is valid and usable. + * A valid CLI exists and has a reasonable file size (> 1MB). + * + * @param cliFile The CLI binary file to check + * @return true if CLI is valid and usable, false otherwise + */ + private boolean isExistingCliValid(File cliFile) { + if (!cliFile.exists()) { + return false; + } + + long fileSize = cliFile.length(); + boolean isValid = fileSize >= MIN_VALID_CLI_SIZE; + + if (isValid) { + log.getLogger().println("[JFrogCliDownloader] Found valid existing CLI: " + cliFile.getAbsolutePath() + + " (size: " + (fileSize / 1024 / 1024) + "MB)"); + } + + return isValid; + } + + /** + * Acquires an exclusive file lock with timeout. + * This method will retry acquiring the lock until either successful or timeout is reached. + * + * @param lockChannel The file channel to lock + * @param lockFile The lock file (for logging purposes) + * @return The acquired FileLock + * @throws IOException If lock cannot be acquired within the timeout period + * @throws InterruptedException If the thread is interrupted while waiting + */ + private FileLock acquireLockWithTimeout(FileChannel lockChannel, File lockFile) throws IOException, InterruptedException { + long startTime = System.currentTimeMillis(); + long elapsedTime = 0; + + while (elapsedTime < LOCK_TIMEOUT_MS) { + // Try to acquire an exclusive lock (non-blocking) + FileLock lock = lockChannel.tryLock(); + if (lock != null) { + return lock; + } + + // Lock is held by another process, wait and retry + log.getLogger().println("[JFrogCliDownloader] Lock held by another process, waiting... (" + + (elapsedTime / 1000) + "s elapsed)"); + Thread.sleep(LOCK_RETRY_INTERVAL_MS); + elapsedTime = System.currentTimeMillis() - startTime; + } + + throw new IOException("Timeout waiting for installation lock after " + + (LOCK_TIMEOUT_MS / 1000) + " seconds. Another installation may be in progress at: " + + lockFile.getAbsolutePath()); + } + + /** + * Performs the actual download operation while holding the installation lock. + * This method contains the core download logic that was previously in invoke(). + * + * @param toolLocation The target directory for CLI installation + * @throws IOException If download fails + * @throws InterruptedException If interrupted during download + */ + private void performDownloadWithLock(File toolLocation) throws IOException, InterruptedException { // An empty string indicates the latest version. String version = StringUtils.defaultIfBlank(providedVersion, RELEASE); String cliUrlSuffix = String.format("/%s/v2-jf/%s/jfrog-cli-%s/%s", repository, version, OsUtils.getOsDetails(), binaryName); @@ -63,7 +303,7 @@ public Void invoke(File toolLocation, VirtualChannel channel) throws IOException if (proxyConfiguration.isProxyConfigured(artifactoryUrl)) { manager.setProxyConfiguration(proxyConfiguration); } - // Getting updated cli binary's sha256 form Artifactory. + // Getting updated cli binary's sha256 from Artifactory. String artifactorySha256 = getArtifactSha256(manager, cliUrlSuffix); if (shouldDownloadTool(toolLocation, artifactorySha256)) { if (version.equals(RELEASE)) { @@ -81,17 +321,17 @@ public Void invoke(File toolLocation, VirtualChannel channel) throws IOException } log.getLogger().println("[JFrogCliDownloader] Download completed successfully"); - return null; } /** * Performs atomic download operations for reliable file installation. + * Used for fresh installations where we MUST succeed. * * APPROACH: * 1. Generate unique temporary file name to avoid conflicts * 2. Download to temporary file * 3. Verify download integrity - * 4. Atomic move from temp to final location + * 4. Atomic move from temp to final location (with retry for Windows) * 5. Set executable permissions * 6. Create SHA256 verification file * 7. Cleanup temporary file on any failure @@ -105,7 +345,6 @@ public Void invoke(File toolLocation, VirtualChannel channel) throws IOException private void performAtomicDownload(ArtifactoryManager manager, String cliUrlSuffix, File toolLocation, String artifactorySha256) throws IOException { - // Phase 1: Generate unique temporary file name to avoid conflicts during parallel downloads String stageName = getStageNameFromThread(); String tempFileName = binaryName + ".tmp." + stageName + "." + @@ -113,8 +352,8 @@ private void performAtomicDownload(ArtifactoryManager manager, String cliUrlSuff Thread.currentThread().getId() + "." + System.nanoTime(); - File temporaryDownloadFile = new File(toolLocation, tempFileName); // Temp file for download - File finalCliExecutable = new File(toolLocation, binaryName); // Final CLI binary location + File temporaryDownloadFile = new File(toolLocation, tempFileName); + File finalCliExecutable = new File(toolLocation, binaryName); log.getLogger().println("[JFrogCliDownloader] Temporary download file: " + temporaryDownloadFile.getAbsolutePath()); log.getLogger().println("[JFrogCliDownloader] Final CLI executable: " + finalCliExecutable.getAbsolutePath()); @@ -122,7 +361,7 @@ private void performAtomicDownload(ArtifactoryManager manager, String cliUrlSuff try { // Download to temporary file log.getLogger().println("[JFrogCliDownloader] Downloading to temporary file"); - File downloadResponse = manager.downloadToFile(cliUrlSuffix, temporaryDownloadFile.getPath()); + manager.downloadToFile(cliUrlSuffix, temporaryDownloadFile.getPath()); // Verify download integrity log.getLogger().println("[JFrogCliDownloader] Verifying download integrity"); @@ -137,20 +376,9 @@ private void performAtomicDownload(ArtifactoryManager manager, String cliUrlSuff log.getLogger().println("[JFrogCliDownloader] Download verified: " + (fileSize / 1024 / 1024) + "MB"); - // Atomic move to final location (only delete existing if move will succeed) + // Move to final location using NIO with retry for Windows file locking issues log.getLogger().println("[JFrogCliDownloader] Moving to final location"); - if (finalCliExecutable.exists()) { - log.getLogger().println("[JFrogCliDownloader] Removing existing CLI binary to replace with new version"); - if (!finalCliExecutable.delete()) { - throw new IOException("Failed to remove existing CLI binary: " + finalCliExecutable.getAbsolutePath()); - } - } - - // Atomic move from temporary file to final location - if (!temporaryDownloadFile.renameTo(finalCliExecutable)) { - throw new IOException("Failed to move temporary file to final location. Temp: " + - temporaryDownloadFile.getAbsolutePath() + ", Final: " + finalCliExecutable.getAbsolutePath()); - } + moveFileWithRetry(temporaryDownloadFile, finalCliExecutable); // Set executable permissions on final CLI binary log.getLogger().println("[JFrogCliDownloader] Setting executable permissions"); @@ -172,6 +400,186 @@ private void performAtomicDownload(ArtifactoryManager manager, String cliUrlSuff } } + /** + * Performs atomic download for upgrade scenario with graceful fallback. + * If the target binary is locked (in use), this method returns false to indicate + * the upgrade was skipped, allowing the caller to use the existing CLI version. + * + * @param manager ArtifactoryManager for download operations + * @param cliUrlSuffix URL suffix for the CLI binary + * @param toolLocation Target directory for installation + * @param artifactorySha256 Expected SHA256 hash for verification + * @param existingCli The existing CLI binary file + * @return true if upgrade succeeded, false if skipped due to file locking + * @throws IOException If download fails for non-recoverable reasons (not file locking) + */ + private boolean performAtomicDownloadForUpgrade(ArtifactoryManager manager, String cliUrlSuffix, + File toolLocation, String artifactorySha256, + File existingCli) throws IOException { + + String stageName = getStageNameFromThread(); + String tempFileName = binaryName + ".tmp." + + stageName + "." + + System.currentTimeMillis() + "." + + Thread.currentThread().getId() + "." + + System.nanoTime(); + + File temporaryDownloadFile = new File(toolLocation, tempFileName); + File finalCliExecutable = new File(toolLocation, binaryName); + + log.getLogger().println("[JFrogCliDownloader] Temporary download file: " + temporaryDownloadFile.getAbsolutePath()); + log.getLogger().println("[JFrogCliDownloader] Final CLI executable: " + finalCliExecutable.getAbsolutePath()); + + try { + // Download to temporary file + log.getLogger().println("[JFrogCliDownloader] Downloading to temporary file"); + manager.downloadToFile(cliUrlSuffix, temporaryDownloadFile.getPath()); + + // Verify download integrity + log.getLogger().println("[JFrogCliDownloader] Verifying download integrity"); + if (!temporaryDownloadFile.exists()) { + throw new IOException("Downloaded file doesn't exist: " + temporaryDownloadFile.getAbsolutePath()); + } + + long fileSize = temporaryDownloadFile.length(); + if (fileSize == 0) { + throw new IOException("Downloaded file is empty: " + temporaryDownloadFile.getAbsolutePath()); + } + + log.getLogger().println("[JFrogCliDownloader] Download verified: " + (fileSize / 1024 / 1024) + "MB"); + + // Try to move to final location - for upgrades, gracefully handle file locking + log.getLogger().println("[JFrogCliDownloader] Attempting to replace existing CLI"); + boolean moveSucceeded = tryMoveFileForUpgrade(temporaryDownloadFile, finalCliExecutable); + + if (!moveSucceeded) { + // File is locked - skip upgrade, use existing + log.getLogger().println("[JFrogCliDownloader] WARNING: Existing CLI is in use by another process. " + + "Upgrade skipped. Using existing version. Upgrade will be attempted in next build."); + cleanupTempFile(temporaryDownloadFile); + return false; + } + + // Set executable permissions on final CLI binary + log.getLogger().println("[JFrogCliDownloader] Setting executable permissions"); + if (!finalCliExecutable.setExecutable(true)) { + throw new IOException("No permission to add execution permission to binary: " + finalCliExecutable.getAbsolutePath()); + } + + // Create SHA256 verification file + log.getLogger().println("[JFrogCliDownloader] Creating SHA256 verification file"); + createSha256File(toolLocation, artifactorySha256); + + return true; + + } catch (IOException e) { + // For upgrade failures, check if we can fall back to existing + if (isFileLockingError(e) && isExistingCliValid(existingCli)) { + log.getLogger().println("[JFrogCliDownloader] WARNING: Upgrade failed due to file locking. " + + "Using existing CLI version. Upgrade will be attempted in next build."); + cleanupTempFile(temporaryDownloadFile); + return false; + } + + // Non-recoverable error + cleanupTempFile(temporaryDownloadFile); + throw e; + } + } + + /** + * Attempts to move a file for upgrade scenario. + * Returns false if file is locked (instead of throwing), allowing graceful fallback. + * + * @param source Source file to move + * @param target Target location + * @return true if move succeeded, false if target is locked + * @throws IOException If move fails for non-locking reasons + */ + private boolean tryMoveFileForUpgrade(File source, File target) throws IOException { + try { + Files.move(source.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING); + log.getLogger().println("[JFrogCliDownloader] File moved successfully to: " + target.getAbsolutePath()); + return true; + } catch (IOException e) { + if (isFileLockingError(e)) { + log.getLogger().println("[JFrogCliDownloader] Target file is locked: " + e.getMessage()); + return false; + } + throw e; + } + } + + /** + * Checks if an exception is caused by Windows file locking. + * + * @param e The exception to check + * @return true if this is a file locking error + */ + private boolean isFileLockingError(Exception e) { + String errorMsg = e.getMessage(); + if (errorMsg == null) { + return false; + } + + return errorMsg.contains("being used by another process") || + errorMsg.contains("Access is denied") || + errorMsg.contains("cannot access the file") || + errorMsg.contains("locked") || + errorMsg.contains("in use"); + } + + /** + * Moves a file to the target location with retry logic for Windows file locking issues. + * Uses Java NIO Files.move with REPLACE_EXISTING option for atomic operation. + * On Windows, if the target file is locked (e.g., being scanned by antivirus), + * this method will retry with exponential backoff. + * + * @param source Source file to move + * @param target Target location + * @throws IOException If move fails after all retries + */ + private void moveFileWithRetry(File source, File target) throws IOException { + int maxRetries = 5; + long retryDelayMs = 1000; + + for (int attempt = 1; attempt <= maxRetries; attempt++) { + try { + // Use Files.move with REPLACE_EXISTING for atomic replacement + // Note: ATOMIC_MOVE is not always supported on Windows, so we use REPLACE_EXISTING + Files.move(source.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING); + log.getLogger().println("[JFrogCliDownloader] File moved successfully to: " + target.getAbsolutePath()); + return; + } catch (IOException e) { + boolean isLastAttempt = (attempt == maxRetries); + String errorMsg = e.getMessage(); + + // Check if this is a Windows file locking issue + boolean isFileLockingIssue = errorMsg != null && + (errorMsg.contains("being used by another process") || + errorMsg.contains("Access is denied") || + errorMsg.contains("cannot access the file")); + + if (isFileLockingIssue && !isLastAttempt) { + log.getLogger().println("[JFrogCliDownloader] File locked, retrying in " + + retryDelayMs + "ms (attempt " + attempt + "/" + maxRetries + ")"); + try { + Thread.sleep(retryDelayMs); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while waiting to retry file move", ie); + } + // Exponential backoff + retryDelayMs *= 2; + } else { + throw new IOException("Failed to move file from " + source.getAbsolutePath() + + " to " + target.getAbsolutePath() + + " after " + attempt + " attempts: " + errorMsg, e); + } + } + } + } + /** * Safely cleans up temporary files. * From c3a7ffd3b9d0691a9f8cf796009dfa63579cee26 Mon Sep 17 00:00:00 2001 From: Bhanu Reddy Date: Thu, 12 Feb 2026 16:58:43 +0530 Subject: [PATCH 02/18] Multiple improvements in jfrog-cli-plugin --- README.md | 47 +++- .../plugins/jfrog/BinaryInstaller.java | 10 +- .../plugins/jfrog/CliEnvConfigurator.java | 3 +- .../jfrog/JfrogBuildInfoPublisher.java | 237 ++++++++++++++++++ .../jenkins/plugins/jfrog/JfrogBuilder.java | 2 +- .../plugins/jfrog/JfrogCliWrapper.java | 186 ++++++++++++++ .../jfrog/callables/JFrogCliDownloader.java | 4 +- .../JfrogBuildInfoPublisher/config.jelly | 11 + .../plugins/jfrog/JfrogBuilder/config.jelly | 2 +- .../jfrog/JfrogCliWrapper/config.jelly | 7 + .../plugins/jfrog/CliEnvConfiguratorTest.java | 3 +- 11 files changed, 498 insertions(+), 14 deletions(-) create mode 100644 src/main/java/io/jenkins/plugins/jfrog/JfrogBuildInfoPublisher.java create mode 100644 src/main/java/io/jenkins/plugins/jfrog/JfrogCliWrapper.java create mode 100644 src/main/resources/io/jenkins/plugins/jfrog/JfrogBuildInfoPublisher/config.jelly create mode 100644 src/main/resources/io/jenkins/plugins/jfrog/JfrogCliWrapper/config.jelly diff --git a/README.md b/README.md index e50dccb1..cf698703 100644 --- a/README.md +++ b/README.md @@ -261,12 +261,25 @@ echo "JFrog CLI version output: $version" The Jenkins JFrog Plugin also supports Freestyle jobs through the "Run JFrog CLI" build step. This allows you to execute JFrog CLI commands without using Pipeline syntax. -### Prerequisites +### Setting up the Build Environment (Recommended for Freestyle jobs) -Before using the Freestyle job support, make sure you have completed the plugin setup: +To make JFrog CLI available to **all** build steps in your Freestyle job (including shell scripts), use the Build Environment wrapper: -1. **Configure JFrog CLI as a tool** (see [Configuring JFrog CLI as a Tool](#configuring-jfrog-cli-as-a-tool)) -2. **Configure your JFrog Platform instance** (see [Installing and configuring the plugin](#installing-and-configuring-the-plugin)) +1. In your Freestyle job configuration, scroll to the **Build Environment** section +2. Check **"Set up JFrog CLI environment"** +3. Select your JFrog CLI installation from the dropdown + +This sets the `JFROG_BINARY_PATH` environment variable for the entire build, making the `jf` command available to: +- All "Run JFrog CLI" build steps (without needing to select an installation in each step) +- Shell/Batch build steps +- Any other build steps that use the environment + +**Benefits:** +- Configure the CLI installation once, use it everywhere +- Shell scripts can use `$JFROG_BINARY_PATH/jf` or add it to PATH +- Consistent environment across all build steps + +> **Note:** This Build Environment option is only available for Freestyle jobs, not Matrix jobs. Matrix jobs run across multiple nodes with potentially different environments. For Matrix jobs, use individual "Run JFrog CLI" build steps with an automatic installation configured (from releases.jfrog.io or Artifactory), which will download the CLI to each node as needed. ### Adding the build step @@ -274,7 +287,8 @@ Before using the Freestyle job support, make sure you have completed the plugin 2. In the Build section, click "Add build step" 3. Select "Run JFrog CLI" from the dropdown menu 4. **Select JFrog CLI Installation** from the dropdown (choose the installation you configured in Global Tool Configuration) - - If you leave it as "(Use JFrog CLI from system PATH)", it will try to use `jf` from your system PATH + - **Recommended:** Select a configured installation (e.g., one set up with automatic download from releases.jfrog.io) + - If you select "(Use pre-installed JFrog CLI from system PATH)", the `jf` command must already be manually installed and available in the PATH on your Jenkins agent. This option will fail if JFrog CLI is not pre-installed. 5. Enter your JFrog CLI command in the "JFrog CLI Command" field (must start with `jf` or `jfrog`) ### Example Freestyle job configuration @@ -318,10 +332,33 @@ jfrog docker push my-image:latest jfrog mvn clean install ``` +### Publishing Build Info + +The plugin automatically collects build information when you run JFrog CLI commands like `jf rt upload`, `jf mvn`, etc. To publish this collected build info to Artifactory, you have two options: + +#### Option 1: Post-build Action (Recommended) + +Add the **"Publish JFrog Build Info"** post-build action to automatically publish build info after your build completes: + +1. In your Freestyle job configuration, scroll to **Post-build Actions** +2. Click **"Add post-build action"** and select **"Publish JFrog Build Info"** +3. Select your JFrog CLI installation (or use the one from Build Environment wrapper) +4. Optionally check **"Publish only on success"** to skip publishing on failed builds + +This ensures build info is always published without needing to add an explicit `jf rt bp` command. + +#### Option 2: Manual Command + +Add a "Run JFrog CLI" build step with the command: +``` +jf rt bp +``` + ### Notes for Freestyle jobs - Commands must start with either `jf` or `jfrog` (e.g., `jf rt ping` or `jfrog rt ping`) - The plugin automatically sets `JFROG_CLI_BUILD_NAME` and `JFROG_CLI_BUILD_NUMBER` environment variables +- Build info is automatically collected during `jf rt upload`, `jf mvn`, `jf gradle`, etc. - Make sure JFrog CLI is configured as a tool in Jenkins (Manage Jenkins → Global Tool Configuration) - The JFrog Platform instance must be configured in Jenkins (Manage Jenkins → Configure System) diff --git a/src/main/java/io/jenkins/plugins/jfrog/BinaryInstaller.java b/src/main/java/io/jenkins/plugins/jfrog/BinaryInstaller.java index 2f683713..ecc103e3 100644 --- a/src/main/java/io/jenkins/plugins/jfrog/BinaryInstaller.java +++ b/src/main/java/io/jenkins/plugins/jfrog/BinaryInstaller.java @@ -107,7 +107,7 @@ public static FilePath performJfrogCliInstallation(FilePath toolLocation, TaskLi try { // Check if CLI already exists and is the correct version FilePath cliPath = toolLocation.child(binaryName); - if (isValidCliInstallation(cliPath, log) && isCorrectVersion(toolLocation, instance, repository, version, log)) { + if (isValidCliInstallation(cliPath, log) && isCorrectVersion(toolLocation, instance, repository, version, binaryName, log)) { log.getLogger().println("[BinaryInstaller] CLI already installed and up-to-date, skipping download"); return toolLocation; } else if (isValidCliInstallation(cliPath, log)) { @@ -185,19 +185,21 @@ private static boolean isValidCliInstallation(FilePath cliPath, TaskListener log * @param instance JFrog platform instance for version checking * @param repository Repository containing the CLI * @param version Version to check + * @param binaryName Name of the CLI binary (e.g., "jf" on Unix, "jf.exe" on Windows) * @param log Task listener for logging * @return true if CLI is the correct version, false otherwise */ private static boolean isCorrectVersion(FilePath toolLocation, JFrogPlatformInstance instance, - String repository, String version, TaskListener log) { + String repository, String version, String binaryName, TaskListener log) { try { // Use the same logic as shouldDownloadTool() from JFrogCliDownloader // but do it here to avoid unnecessary JFrogCliDownloader.invoke() calls JenkinsProxyConfiguration proxyConfiguration = new JenkinsProxyConfiguration(); - String cliUrlSuffix = String.format("/%s/v2-jf/%s/jfrog-cli-%s/jf", repository, + // Use binaryName to construct the correct URL suffix (handles Windows jf.exe vs Unix jf) + String cliUrlSuffix = String.format("/%s/v2-jf/%s/jfrog-cli-%s/%s", repository, StringUtils.defaultIfBlank(version, "[RELEASE]"), - OsUtils.getOsDetails()); + OsUtils.getOsDetails(), binaryName); JenkinsBuildInfoLog buildInfoLog = new JenkinsBuildInfoLog(log); String artifactoryUrl = instance.inferArtifactoryUrl(); diff --git a/src/main/java/io/jenkins/plugins/jfrog/CliEnvConfigurator.java b/src/main/java/io/jenkins/plugins/jfrog/CliEnvConfigurator.java index f63682bb..243d003a 100644 --- a/src/main/java/io/jenkins/plugins/jfrog/CliEnvConfigurator.java +++ b/src/main/java/io/jenkins/plugins/jfrog/CliEnvConfigurator.java @@ -44,7 +44,8 @@ static void configureCliEnv(EnvVars env, String jfrogHomeTempDir, JFrogCliConfig } if (encryptionKey.shouldEncrypt()) { // Set up a random encryption key to make sure no raw text secrets are stored in the file system - env.putIfAbsent(JFROG_CLI_ENCRYPTION_KEY, encryptionKey.getKeyOrFilePath()); + // getKey() returns the actual 32-character encryption key content, not the file path + env.putIfAbsent(JFROG_CLI_ENCRYPTION_KEY, encryptionKey.getKey()); } } diff --git a/src/main/java/io/jenkins/plugins/jfrog/JfrogBuildInfoPublisher.java b/src/main/java/io/jenkins/plugins/jfrog/JfrogBuildInfoPublisher.java new file mode 100644 index 00000000..5bd34ba9 --- /dev/null +++ b/src/main/java/io/jenkins/plugins/jfrog/JfrogBuildInfoPublisher.java @@ -0,0 +1,237 @@ +package io.jenkins.plugins.jfrog; + +import hudson.EnvVars; +import hudson.Extension; +import hudson.FilePath; +import hudson.Launcher; +import hudson.model.AbstractBuild; +import hudson.model.AbstractProject; +import hudson.model.BuildListener; +import hudson.model.Result; +import hudson.tasks.BuildStepDescriptor; +import hudson.tasks.BuildStepMonitor; +import hudson.tasks.Notifier; +import hudson.tasks.Publisher; +import hudson.util.ArgumentListBuilder; +import hudson.util.ListBoxModel; +import io.jenkins.plugins.jfrog.actions.JFrogCliConfigEncryption; +import org.apache.commons.lang3.StringUtils; +import org.jenkinsci.Symbol; +import org.kohsuke.stapler.DataBoundConstructor; +import org.kohsuke.stapler.DataBoundSetter; + +import javax.annotation.Nonnull; +import java.io.ByteArrayOutputStream; +import java.io.IOException; + +import static io.jenkins.plugins.jfrog.JfStep.addBuildInfoActionIfNeeded; +import static io.jenkins.plugins.jfrog.JfrogInstallation.JFROG_BINARY_PATH; + +/** + * Post-build action to publish JFrog Build Info. + * This automatically runs 'jf rt build-publish' after the build completes, + * publishing collected build information to Artifactory. + */ +public class JfrogBuildInfoPublisher extends Notifier { + + private String jfrogInstallation; + private boolean publishOnlyOnSuccess = true; + + @DataBoundConstructor + public JfrogBuildInfoPublisher() { + } + + public String getJfrogInstallation() { + return jfrogInstallation; + } + + @DataBoundSetter + public void setJfrogInstallation(String jfrogInstallation) { + this.jfrogInstallation = jfrogInstallation; + } + + public boolean isPublishOnlyOnSuccess() { + return publishOnlyOnSuccess; + } + + @DataBoundSetter + public void setPublishOnlyOnSuccess(boolean publishOnlyOnSuccess) { + this.publishOnlyOnSuccess = publishOnlyOnSuccess; + } + + @Override + public BuildStepMonitor getRequiredMonitorService() { + return BuildStepMonitor.NONE; + } + + @Override + public boolean perform(AbstractBuild build, Launcher launcher, BuildListener listener) + throws InterruptedException, IOException { + + // Check if we should skip based on build result + Result buildResult = build.getResult(); + if (publishOnlyOnSuccess && buildResult != null && buildResult.isWorseThan(Result.SUCCESS)) { + listener.getLogger().println("[JFrog Build Info] Skipping publish - build result is " + buildResult); + return true; + } + + FilePath workspace = build.getWorkspace(); + if (workspace == null) { + listener.error("[JFrog Build Info] Workspace is null"); + return false; + } + + EnvVars env = build.getEnvironment(listener); + + // Setup JFrog CLI installation environment if specified + if (StringUtils.isNotBlank(jfrogInstallation)) { + JfrogInstallation installation = getInstallation(); + if (installation != null) { + hudson.model.Node node = build.getBuiltOn(); + if (node != null) { + installation = installation.forNode(node, listener); + } + if (installation != null) { + installation = installation.forEnvironment(env); + } + if (installation != null) { + installation.buildEnvVars(env); + } + } + } + + // Check if JFROG_BINARY_PATH is set (either from installation or Build Environment wrapper) + if (!env.containsKey(JFROG_BINARY_PATH)) { + listener.getLogger().println("[JFrog Build Info] Using JFrog CLI from system PATH"); + } + + boolean isWindows = !launcher.isUnix(); + String jfrogBinaryPath = Utils.getJFrogCLIPath(env, isWindows); + + // Setup JFrog environment + JFrogCliConfigEncryption jfrogCliConfigEncryption = build.getAction(JFrogCliConfigEncryption.class); + if (jfrogCliConfigEncryption == null) { + jfrogCliConfigEncryption = new JFrogCliConfigEncryption(env); + build.addAction(jfrogCliConfigEncryption); + } + + FilePath jfrogHomeTempDir = Utils.createAndGetJfrogCliHomeTempDir(workspace, String.valueOf(build.getNumber())); + CliEnvConfigurator.configureCliEnv(env, jfrogHomeTempDir.getRemote(), jfrogCliConfigEncryption); + + // Build the 'jf rt build-publish' command + ArgumentListBuilder builder = new ArgumentListBuilder(); + builder.add(jfrogBinaryPath).add("rt").add("bp"); + if (isWindows) { + builder = builder.toWindowsCommand(); + } + + listener.getLogger().println("[JFrog Build Info] Publishing build info..."); + listener.getLogger().println("[JFrog Build Info] Build name: " + env.get("JFROG_CLI_BUILD_NAME")); + listener.getLogger().println("[JFrog Build Info] Build number: " + env.get("JFROG_CLI_BUILD_NUMBER")); + + try (ByteArrayOutputStream taskOutputStream = new ByteArrayOutputStream()) { + JfTaskListener jfTaskListener = new JfTaskListener(listener, taskOutputStream); + Launcher.ProcStarter jfLauncher = launcher.launch() + .envs(env) + .pwd(workspace) + .stdout(jfTaskListener); + + // Configure servers if needed + if (shouldConfig(jfrogHomeTempDir)) { + JfStep.Execution.configAllServersForBuilder( + jfLauncher, jfrogBinaryPath, isWindows, build.getParent(), false + ); + } + + // Run 'jf rt bp' + int exitValue = jfLauncher.cmds(builder).join(); + if (exitValue != 0) { + listener.error("[JFrog Build Info] Failed to publish build info (exit code: " + exitValue + ")"); + return false; + } + + // Add build info badge to Jenkins UI + String[] args = {"rt", "bp"}; + addBuildInfoActionIfNeeded(args, new JenkinsBuildInfoLog(listener), build, taskOutputStream); + + listener.getLogger().println("[JFrog Build Info] Build info published successfully"); + return true; + } catch (Exception e) { + listener.error("[JFrog Build Info] Error publishing build info: " + e.getMessage()); + return false; + } + } + + /** + * Check if servers need to be configured. + */ + private boolean shouldConfig(FilePath jfrogHomeTempDir) throws IOException, InterruptedException { + if (jfrogHomeTempDir == null || !jfrogHomeTempDir.exists()) { + return true; + } + + for (FilePath file : jfrogHomeTempDir.list()) { + if (file != null && file.getName().contains("jfrog-cli.conf")) { + return false; + } + } + return true; + } + + /** + * Get the JFrog installation by name. + */ + private JfrogInstallation getInstallation() { + if (jfrogInstallation == null) { + return null; + } + + JfrogInstallation[] installations = ((DescriptorImpl) getDescriptor()).getInstallations(); + if (installations == null) { + return null; + } + + for (JfrogInstallation installation : installations) { + if (installation != null && jfrogInstallation.equals(installation.getName())) { + return installation; + } + } + return null; + } + + @Extension + @Symbol("jfrogPublishBuildInfo") + public static final class DescriptorImpl extends BuildStepDescriptor { + + @Nonnull + @Override + public String getDisplayName() { + return "Publish JFrog Build Info"; + } + + @Override + public boolean isApplicable(Class jobType) { + return true; + } + + /** + * Get all configured JFrog CLI installations. + */ + public JfrogInstallation[] getInstallations() { + jenkins.model.Jenkins jenkinsInstance = jenkins.model.Jenkins.get(); + return jenkinsInstance.getDescriptorByType(JfrogInstallation.DescriptorImpl.class).getInstallations(); + } + + /** + * Populate the dropdown list of JFrog CLI installations. + */ + public ListBoxModel doFillJfrogInstallationItems() { + ListBoxModel items = new ListBoxModel(); + items.add("(Use pre-installed JFrog CLI from system PATH)", ""); + for (JfrogInstallation installation : getInstallations()) { + items.add(installation.getName(), installation.getName()); + } + return items; + } + } +} diff --git a/src/main/java/io/jenkins/plugins/jfrog/JfrogBuilder.java b/src/main/java/io/jenkins/plugins/jfrog/JfrogBuilder.java index 77c7c63d..7f3e9656 100644 --- a/src/main/java/io/jenkins/plugins/jfrog/JfrogBuilder.java +++ b/src/main/java/io/jenkins/plugins/jfrog/JfrogBuilder.java @@ -352,7 +352,7 @@ public JfrogInstallation[] getInstallations() { */ public ListBoxModel doFillJfrogInstallationItems() { ListBoxModel items = new ListBoxModel(); - items.add("(Use JFrog CLI from system PATH)", ""); + items.add("(Use pre-installed JFrog CLI from system PATH)", ""); for (JfrogInstallation installation : getInstallations()) { items.add(installation.getName(), installation.getName()); } diff --git a/src/main/java/io/jenkins/plugins/jfrog/JfrogCliWrapper.java b/src/main/java/io/jenkins/plugins/jfrog/JfrogCliWrapper.java new file mode 100644 index 00000000..2d065676 --- /dev/null +++ b/src/main/java/io/jenkins/plugins/jfrog/JfrogCliWrapper.java @@ -0,0 +1,186 @@ +package io.jenkins.plugins.jfrog; + +import hudson.EnvVars; +import hudson.Extension; +import hudson.FilePath; +import hudson.Launcher; +import hudson.model.AbstractProject; +import hudson.model.Run; +import hudson.model.TaskListener; +import hudson.tasks.BuildWrapperDescriptor; +import hudson.util.ListBoxModel; +import jenkins.tasks.SimpleBuildWrapper; +import org.jenkinsci.Symbol; +import org.kohsuke.stapler.DataBoundConstructor; +import org.kohsuke.stapler.DataBoundSetter; + +import javax.annotation.Nonnull; +import java.io.IOException; + +/** + * Build Environment wrapper that sets up JFrog CLI for the entire build. + * This allows all build steps in a Freestyle job to use the configured JFrog CLI + * installation without having to specify it in each step. + * + * When this wrapper is enabled, the JFROG_BINARY_PATH environment variable is set + * for the entire build, making the 'jf' command available to all build steps, + * including shell scripts and other plugins. + * + *

Note: This wrapper is only available for Freestyle jobs, not Matrix jobs. + * Matrix jobs run across multiple nodes where CLI installations may differ. + * For Matrix jobs, use individual "Run JFrog CLI" build steps with automatic + * installation configured, which will download the CLI to each node as needed.

+ */ +public class JfrogCliWrapper extends SimpleBuildWrapper { + + private String jfrogInstallation; + + @DataBoundConstructor + public JfrogCliWrapper() { + } + + public String getJfrogInstallation() { + return jfrogInstallation; + } + + @DataBoundSetter + public void setJfrogInstallation(String jfrogInstallation) { + this.jfrogInstallation = jfrogInstallation; + } + + @Override + public void setUp( + Context context, + Run build, + FilePath workspace, + Launcher launcher, + TaskListener listener, + EnvVars initialEnvironment + ) throws IOException, InterruptedException { + if (jfrogInstallation == null || jfrogInstallation.isEmpty()) { + listener.getLogger().println("[JFrog CLI] No installation selected, using system PATH"); + return; + } + + JfrogInstallation installation = getInstallation(); + if (installation == null) { + listener.error("[JFrog CLI] Installation '" + jfrogInstallation + "' not found"); + return; + } + + // Resolve the installation for the current node + hudson.model.Node node = workspaceToNode(workspace); + if (node != null) { + installation = installation.forNode(node, listener); + } + installation = installation.forEnvironment(initialEnvironment); + + // Add environment variables that will persist for the entire build + EnvVars envVars = new EnvVars(); + installation.buildEnvVars(envVars); + + for (java.util.Map.Entry entry : envVars.entrySet()) { + context.env(entry.getKey(), entry.getValue()); + } + + listener.getLogger().println("[JFrog CLI] Using installation: " + jfrogInstallation); + listener.getLogger().println("[JFrog CLI] Binary path: " + envVars.get(JfrogInstallation.JFROG_BINARY_PATH)); + } + + /** + * Get the JFrog installation by name. + */ + private JfrogInstallation getInstallation() { + if (jfrogInstallation == null) { + return null; + } + + JfrogInstallation[] installations = ((DescriptorImpl) getDescriptor()).getInstallations(); + if (installations == null) { + return null; + } + + for (JfrogInstallation installation : installations) { + if (installation != null && jfrogInstallation.equals(installation.getName())) { + return installation; + } + } + return null; + } + + /** + * Get the node from workspace. + */ + private hudson.model.Node workspaceToNode(FilePath workspace) { + jenkins.model.Jenkins jenkinsInstance = jenkins.model.Jenkins.getInstanceOrNull(); + if (jenkinsInstance == null || workspace == null) { + return null; + } + + // Check if workspace is on master + if (workspace.getChannel() == jenkinsInstance.getChannel()) { + return jenkinsInstance; + } + + // Find the node that owns this workspace + for (hudson.model.Node node : jenkinsInstance.getNodes()) { + if (node.getChannel() == workspace.getChannel()) { + return node; + } + } + return null; + } + + @Extension + @Symbol("jfrogCliEnv") + public static final class DescriptorImpl extends BuildWrapperDescriptor { + + @Nonnull + @Override + public String getDisplayName() { + return "Set up JFrog CLI environment"; + } + + /** + * This wrapper is only available for Freestyle jobs, not Matrix jobs. + * + * Matrix jobs run across multiple nodes where CLI installations need to be + * handled per-node. For Matrix jobs, users should use individual "Run JFrog CLI" + * build steps with automatic installation configured, which will download + * the CLI to each node as needed. + * + * @param item The project to check + * @return true if this wrapper can be used with the project + */ + @Override + public boolean isApplicable(AbstractProject item) { + // Exclude Matrix projects - they run across multiple nodes + // Use class name check to avoid hard dependency on matrix-project plugin + String className = item.getClass().getName(); + if (className.contains("MatrixProject") || className.contains("MatrixConfiguration")) { + return false; + } + return true; + } + + /** + * Get all configured JFrog CLI installations. + */ + public JfrogInstallation[] getInstallations() { + jenkins.model.Jenkins jenkinsInstance = jenkins.model.Jenkins.get(); + return jenkinsInstance.getDescriptorByType(JfrogInstallation.DescriptorImpl.class).getInstallations(); + } + + /** + * Populate the dropdown list of JFrog CLI installations. + */ + public ListBoxModel doFillJfrogInstallationItems() { + ListBoxModel items = new ListBoxModel(); + items.add("(Use pre-installed JFrog CLI from system PATH)", ""); + for (JfrogInstallation installation : getInstallations()) { + items.add(installation.getName(), installation.getName()); + } + return items; + } + } +} diff --git a/src/main/java/io/jenkins/plugins/jfrog/callables/JFrogCliDownloader.java b/src/main/java/io/jenkins/plugins/jfrog/callables/JFrogCliDownloader.java index 61383b90..1858eb4d 100644 --- a/src/main/java/io/jenkins/plugins/jfrog/callables/JFrogCliDownloader.java +++ b/src/main/java/io/jenkins/plugins/jfrog/callables/JFrogCliDownloader.java @@ -80,7 +80,9 @@ public Void invoke(File toolLocation, VirtualChannel channel) throws IOException // Ensure the tool location directory exists if (!toolLocation.exists()) { - toolLocation.mkdirs(); + if (!toolLocation.mkdirs()) { + throw new IOException("Failed to create tool location directory: " + toolLocation.getAbsolutePath()); + } } // Check if this is a fresh install or an upgrade diff --git a/src/main/resources/io/jenkins/plugins/jfrog/JfrogBuildInfoPublisher/config.jelly b/src/main/resources/io/jenkins/plugins/jfrog/JfrogBuildInfoPublisher/config.jelly new file mode 100644 index 00000000..f4b02e82 --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/jfrog/JfrogBuildInfoPublisher/config.jelly @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/src/main/resources/io/jenkins/plugins/jfrog/JfrogBuilder/config.jelly b/src/main/resources/io/jenkins/plugins/jfrog/JfrogBuilder/config.jelly index 78970333..04e2d5a9 100644 --- a/src/main/resources/io/jenkins/plugins/jfrog/JfrogBuilder/config.jelly +++ b/src/main/resources/io/jenkins/plugins/jfrog/JfrogBuilder/config.jelly @@ -1,7 +1,7 @@ + description="Select the JFrog CLI installation to use. If you choose 'Use pre-installed JFrog CLI from system PATH', the 'jf' command must already be installed and available in the PATH on your Jenkins agent. Otherwise, configure automatic installations in 'Manage Jenkins' → 'Global Tool Configuration'."> + + + + + diff --git a/src/test/java/io/jenkins/plugins/jfrog/CliEnvConfiguratorTest.java b/src/test/java/io/jenkins/plugins/jfrog/CliEnvConfiguratorTest.java index 14f000d8..0a41c9c6 100644 --- a/src/test/java/io/jenkins/plugins/jfrog/CliEnvConfiguratorTest.java +++ b/src/test/java/io/jenkins/plugins/jfrog/CliEnvConfiguratorTest.java @@ -46,7 +46,8 @@ public void configEncryptionTest() { assertEquals(32, configEncryption.getKey().length()); invokeConfigureCliEnv("a/b/c", configEncryption); - assertEnv(envVars, JFROG_CLI_ENCRYPTION_KEY, configEncryption.getKeyOrFilePath()); + // The environment variable should contain the actual 32-character encryption key, not the file path + assertEnv(envVars, JFROG_CLI_ENCRYPTION_KEY, configEncryption.getKey()); } @Test From e48559b738894762ae03ba7e6b3648e548bc980b Mon Sep 17 00:00:00 2001 From: Bhanu Reddy Date: Thu, 12 Feb 2026 17:03:33 +0530 Subject: [PATCH 03/18] Added null check --- .../java/io/jenkins/plugins/jfrog/JfrogCliWrapper.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/main/java/io/jenkins/plugins/jfrog/JfrogCliWrapper.java b/src/main/java/io/jenkins/plugins/jfrog/JfrogCliWrapper.java index 2d065676..4c7dd575 100644 --- a/src/main/java/io/jenkins/plugins/jfrog/JfrogCliWrapper.java +++ b/src/main/java/io/jenkins/plugins/jfrog/JfrogCliWrapper.java @@ -72,8 +72,16 @@ public void setUp( hudson.model.Node node = workspaceToNode(workspace); if (node != null) { installation = installation.forNode(node, listener); + if (installation == null) { + listener.error("[JFrog CLI] Installation '" + jfrogInstallation + "' is not available for current node"); + return; + } } installation = installation.forEnvironment(initialEnvironment); + if (installation == null) { + listener.error("[JFrog CLI] Installation '" + jfrogInstallation + "' is not available for current environment"); + return; + } // Add environment variables that will persist for the entire build EnvVars envVars = new EnvVars(); From 2041868cfcb02e63f0748065337153a7b0dda8ef Mon Sep 17 00:00:00 2001 From: Bhanu Reddy Date: Thu, 12 Feb 2026 17:28:18 +0530 Subject: [PATCH 04/18] Fixed minor static checks --- .../plugins/jfrog/JfrogCliWrapper.java | 8 -------- .../actions/JFrogCliConfigEncryption.java | 19 ++++++++++++++----- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/src/main/java/io/jenkins/plugins/jfrog/JfrogCliWrapper.java b/src/main/java/io/jenkins/plugins/jfrog/JfrogCliWrapper.java index 4c7dd575..2d065676 100644 --- a/src/main/java/io/jenkins/plugins/jfrog/JfrogCliWrapper.java +++ b/src/main/java/io/jenkins/plugins/jfrog/JfrogCliWrapper.java @@ -72,16 +72,8 @@ public void setUp( hudson.model.Node node = workspaceToNode(workspace); if (node != null) { installation = installation.forNode(node, listener); - if (installation == null) { - listener.error("[JFrog CLI] Installation '" + jfrogInstallation + "' is not available for current node"); - return; - } } installation = installation.forEnvironment(initialEnvironment); - if (installation == null) { - listener.error("[JFrog CLI] Installation '" + jfrogInstallation + "' is not available for current environment"); - return; - } // Add environment variables that will persist for the entire build EnvVars envVars = new EnvVars(); diff --git a/src/main/java/io/jenkins/plugins/jfrog/actions/JFrogCliConfigEncryption.java b/src/main/java/io/jenkins/plugins/jfrog/actions/JFrogCliConfigEncryption.java index 94a0627c..acecbc4b 100644 --- a/src/main/java/io/jenkins/plugins/jfrog/actions/JFrogCliConfigEncryption.java +++ b/src/main/java/io/jenkins/plugins/jfrog/actions/JFrogCliConfigEncryption.java @@ -20,6 +20,7 @@ public class JFrogCliConfigEncryption implements Action { private boolean shouldEncrypt; private String keyOrPath; + private String keyContent; public JFrogCliConfigEncryption(EnvVars env) { if (env.containsKey(JFROG_CLI_HOME_DIR)) { @@ -40,22 +41,30 @@ public JFrogCliConfigEncryption(EnvVars env) { Path keyFilePath = encryptionDir.resolve(fileName); String encryptionKeyContent = UUID.randomUUID().toString().replaceAll("-", ""); Files.write(keyFilePath, encryptionKeyContent.getBytes(StandardCharsets.UTF_8)); - this.keyOrPath =keyFilePath.toString(); + this.keyOrPath = keyFilePath.toString(); + this.keyContent = encryptionKeyContent; } catch (IOException e) { throw new RuntimeException(e); } } public String getKey() { + if (this.keyContent != null && !this.keyContent.isEmpty()) { + return this.keyContent; + } if (this.keyOrPath == null || this.keyOrPath.isEmpty()) { - return null; + throw new IllegalStateException("Encryption key is not initialized"); } try { byte[] keyBytes = Files.readAllBytes(Paths.get(this.keyOrPath)); - return new String(keyBytes, StandardCharsets.UTF_8).trim(); + String key = new String(keyBytes, StandardCharsets.UTF_8).trim(); + if (key.isEmpty()) { + throw new IllegalStateException("Encryption key file is empty: " + this.keyOrPath); + } + this.keyContent = key; + return key; } catch (IOException e) { - System.err.println("Error reading encryption key file: " + e.getMessage()); - return null; + throw new IllegalStateException("Failed reading encryption key file: " + this.keyOrPath, e); } } From 7141ccdde9baabbbe51decd9f17955842ccf1d40 Mon Sep 17 00:00:00 2001 From: Bhanu Reddy Date: Thu, 19 Feb 2026 01:53:04 +0530 Subject: [PATCH 05/18] Removed file lock based sync for upgrading jfrog cli --- .../plugins/jfrog/BinaryInstaller.java | 63 +++++- .../jfrog/callables/JFrogCliDownloader.java | 198 +++++------------- 2 files changed, 109 insertions(+), 152 deletions(-) diff --git a/src/main/java/io/jenkins/plugins/jfrog/BinaryInstaller.java b/src/main/java/io/jenkins/plugins/jfrog/BinaryInstaller.java index ecc103e3..134dba7f 100644 --- a/src/main/java/io/jenkins/plugins/jfrog/BinaryInstaller.java +++ b/src/main/java/io/jenkins/plugins/jfrog/BinaryInstaller.java @@ -24,6 +24,8 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.logging.Logger; +import static org.jfrog.build.client.DownloadResponse.SHA256_HEADER_NAME; + /** * Installer for JFrog CLI binary. * @@ -107,10 +109,11 @@ public static FilePath performJfrogCliInstallation(FilePath toolLocation, TaskLi try { // Check if CLI already exists and is the correct version FilePath cliPath = toolLocation.child(binaryName); - if (isValidCliInstallation(cliPath, log) && isCorrectVersion(toolLocation, instance, repository, version, binaryName, log)) { + boolean validCliExists = isValidCliInstallation(cliPath, log); + if (validCliExists && isCorrectVersion(toolLocation, instance, repository, version, binaryName, log)) { log.getLogger().println("[BinaryInstaller] CLI already installed and up-to-date, skipping download"); return toolLocation; - } else if (isValidCliInstallation(cliPath, log)) { + } else if (validCliExists) { log.getLogger().println("[BinaryInstaller] CLI exists but version mismatch detected, proceeding with upgrade"); } else { log.getLogger().println("[BinaryInstaller] No valid CLI installation found, proceeding with fresh installation"); @@ -149,7 +152,7 @@ private static String createLockKey(FilePath toolLocation, String binaryName, St return toolLocation.getRemote() + "/" + binaryName + "/" + version; } catch (Exception e) { // Fallback to a simpler key if remote path access fails - return toolLocation.toString() + "/" + binaryName + "/" + version; + return "unknown-tool-location/" + binaryName + "/" + version; } } @@ -165,7 +168,7 @@ private static boolean isValidCliInstallation(FilePath cliPath, TaskListener log if (cliPath.exists()) { // Check if file is executable and has reasonable size (> 1MB) long fileSize = cliPath.length(); - if (fileSize > 1024 * 1024) { // > 1MB + if (fileSize > 1024 * 1024 && isExecutable(cliPath)) { // > 1MB log.getLogger().println("[BinaryInstaller] Found existing CLI: " + cliPath.getRemote() + " (size: " + (fileSize / 1024 / 1024) + "MB)"); return true; @@ -176,6 +179,26 @@ private static boolean isValidCliInstallation(FilePath cliPath, TaskListener log } return false; } + + /** + * Verify the CLI file is executable on the target node. + * On Windows, treat .exe files as executable. + */ + private static boolean isExecutable(FilePath cliPath) throws IOException, InterruptedException { + return cliPath.act(new MasterToSlaveFileCallable() { + @Override + public Boolean invoke(File file, VirtualChannel channel) { + if (!file.exists() || file.isDirectory()) { + return false; + } + String name = file.getName().toLowerCase(); + if (name.endsWith(".exe")) { + return true; + } + return file.canExecute(); + } + }); + } /** * Check if the installed CLI is the correct version by comparing SHA256 hashes. @@ -216,8 +239,14 @@ private static boolean isCorrectVersion(FilePath toolLocation, JFrogPlatformInst // Get expected SHA256 from Artifactory String expectedSha256 = getArtifactSha256(manager, cliUrlSuffix); if (expectedSha256.isEmpty()) { - log.getLogger().println("[BinaryInstaller] No SHA256 available from server, assuming version check needed"); - return false; // If no SHA256, let download process handle it + log.getLogger().println("[BinaryInstaller] No SHA256 available from server"); + boolean hasLocalChecksum = hasLocalChecksumFile(toolLocation); + if (hasLocalChecksum) { + log.getLogger().println("[BinaryInstaller] Existing CLI and local checksum found, skipping upgrade"); + return true; + } + log.getLogger().println("[BinaryInstaller] Local checksum missing, proceeding with download check"); + return false; } // Check local SHA256 file @@ -240,6 +269,24 @@ public Boolean invoke(File f, VirtualChannel channel) throws IOException, Interr return false; // If version check fails, let download process handle it } } + + /** + * Check if local checksum file exists and is non-empty. + */ + private static boolean hasLocalChecksumFile(FilePath toolLocation) { + try { + return toolLocation.act(new MasterToSlaveFileCallable() { + @Override + public Boolean invoke(File f, VirtualChannel channel) throws IOException, InterruptedException { + File sha256File = new File(f, "sha256"); + return sha256File.exists() && sha256File.length() > 0; + } + }); + } catch (Exception e) { + LOGGER.fine("Failed to verify local checksum file: " + e.getMessage()); + return false; + } + } /** * Get SHA256 hash from Artifactory headers (same logic as in JFrogCliDownloader) @@ -247,7 +294,9 @@ public Boolean invoke(File f, VirtualChannel channel) throws IOException, Interr private static String getArtifactSha256(ArtifactoryManager manager, String cliUrlSuffix) throws IOException { Header[] headers = manager.downloadHeaders(cliUrlSuffix); for (Header header : headers) { - if (header.getName().equalsIgnoreCase("X-Checksum-Sha256")) { + String headerName = header.getName(); + if (headerName.equalsIgnoreCase(SHA256_HEADER_NAME) || + headerName.equalsIgnoreCase("X-Artifactory-Checksum-Sha256")) { return header.getValue(); } } diff --git a/src/main/java/io/jenkins/plugins/jfrog/callables/JFrogCliDownloader.java b/src/main/java/io/jenkins/plugins/jfrog/callables/JFrogCliDownloader.java index 1858eb4d..f18e1f66 100644 --- a/src/main/java/io/jenkins/plugins/jfrog/callables/JFrogCliDownloader.java +++ b/src/main/java/io/jenkins/plugins/jfrog/callables/JFrogCliDownloader.java @@ -14,11 +14,10 @@ import org.jfrog.build.extractor.clientConfiguration.client.artifactory.ArtifactoryManager; import java.io.File; -import java.io.FileOutputStream; import java.io.IOException; -import java.nio.channels.FileChannel; -import java.nio.channels.FileLock; import java.nio.charset.StandardCharsets; +import java.nio.file.AccessDeniedException; +import java.nio.file.FileSystemException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; @@ -43,24 +42,6 @@ public class JFrogCliDownloader extends MasterToSlaveFileCallable { */ private static final String RELEASE = "[RELEASE]"; - /** - * Lock file name used to coordinate parallel installations on the same agent. - * This is especially important on Windows where file operations are not atomic - * and parallel steps can conflict when trying to install the CLI simultaneously. - */ - private static final String LOCK_FILE_NAME = ".jfrog-cli-install.lock"; - - /** - * Maximum time to wait for acquiring the installation lock (in milliseconds). - * Only used for fresh installations where we must succeed. - */ - private static final long LOCK_TIMEOUT_MS = 300000; - - /** - * Retry interval when waiting for the installation lock (in milliseconds). - */ - private static final long LOCK_RETRY_INTERVAL_MS = 1000; - /** * Minimum valid CLI binary size in bytes (1MB). * Used to determine if an existing CLI installation is valid. @@ -90,91 +71,16 @@ public Void invoke(File toolLocation, VirtualChannel channel) throws IOException boolean isFreshInstall = !isExistingCliValid(existingCli); if (isFreshInstall) { - log.getLogger().println("[JFrogCliDownloader] Fresh installation detected - will wait for lock if needed"); - performFreshInstallation(toolLocation); + log.getLogger().println("[JFrogCliDownloader] Fresh installation detected"); + performDownloadWithLock(toolLocation); } else { log.getLogger().println("[JFrogCliDownloader] Existing CLI found - attempting upgrade"); - performUpgrade(toolLocation, existingCli); + performDownloadWithLockForUpgrade(toolLocation, existingCli); } return null; } - /** - * Performs a fresh CLI installation with lock waiting. - * For fresh installations, we MUST succeed, so we wait for the lock with timeout. - * - * @param toolLocation The target directory for CLI installation - * @throws IOException If installation fails - * @throws InterruptedException If interrupted during installation - */ - private void performFreshInstallation(File toolLocation) throws IOException, InterruptedException { - File lockFile = new File(toolLocation, LOCK_FILE_NAME); - - log.getLogger().println("[JFrogCliDownloader] Acquiring installation lock: " + lockFile.getAbsolutePath()); - - try (FileOutputStream lockFileStream = new FileOutputStream(lockFile); - FileChannel lockChannel = lockFileStream.getChannel()) { - - // For fresh install, wait for lock with timeout (must succeed) - FileLock lock = acquireLockWithTimeout(lockChannel, lockFile); - - try { - log.getLogger().println("[JFrogCliDownloader] Installation lock acquired, proceeding with fresh installation"); - - // Re-check after acquiring lock - another process might have installed it - File existingCli = new File(toolLocation, binaryName); - if (isExistingCliValid(existingCli)) { - log.getLogger().println("[JFrogCliDownloader] CLI was installed by another process while waiting for lock"); - // Still need to check if version matches - performDownloadWithLock(toolLocation); - } else { - performDownloadWithLock(toolLocation); - } - } finally { - lock.release(); - log.getLogger().println("[JFrogCliDownloader] Installation lock released"); - } - } - } - - /** - * Attempts to upgrade an existing CLI installation. - * For upgrades, if the binary is locked (in use by another process), we gracefully - * skip the upgrade and use the existing version. The upgrade will be attempted - * in the next build. - * - * @param toolLocation The target directory for CLI installation - * @param existingCli The existing CLI binary file - * @throws IOException If upgrade fails for non-recoverable reasons - * @throws InterruptedException If interrupted during upgrade - */ - private void performUpgrade(File toolLocation, File existingCli) throws IOException, InterruptedException { - File lockFile = new File(toolLocation, LOCK_FILE_NAME); - - try (FileOutputStream lockFileStream = new FileOutputStream(lockFile); - FileChannel lockChannel = lockFileStream.getChannel()) { - - // For upgrades, try to acquire lock quickly (non-blocking) - FileLock lock = lockChannel.tryLock(); - - if (lock == null) { - // Lock is held by another process - skip upgrade, use existing - log.getLogger().println("[JFrogCliDownloader] WARNING: Another installation is in progress. " + - "Using existing CLI version. Upgrade will be attempted in next build."); - return; - } - - try { - log.getLogger().println("[JFrogCliDownloader] Lock acquired, attempting upgrade"); - performDownloadWithLockForUpgrade(toolLocation, existingCli); - } finally { - lock.release(); - log.getLogger().println("[JFrogCliDownloader] Installation lock released"); - } - } - } - /** * Performs download for upgrade scenario with graceful fallback. * If the binary is locked during replacement, we skip the upgrade and use existing. @@ -251,41 +157,7 @@ private boolean isExistingCliValid(File cliFile) { } /** - * Acquires an exclusive file lock with timeout. - * This method will retry acquiring the lock until either successful or timeout is reached. - * - * @param lockChannel The file channel to lock - * @param lockFile The lock file (for logging purposes) - * @return The acquired FileLock - * @throws IOException If lock cannot be acquired within the timeout period - * @throws InterruptedException If the thread is interrupted while waiting - */ - private FileLock acquireLockWithTimeout(FileChannel lockChannel, File lockFile) throws IOException, InterruptedException { - long startTime = System.currentTimeMillis(); - long elapsedTime = 0; - - while (elapsedTime < LOCK_TIMEOUT_MS) { - // Try to acquire an exclusive lock (non-blocking) - FileLock lock = lockChannel.tryLock(); - if (lock != null) { - return lock; - } - - // Lock is held by another process, wait and retry - log.getLogger().println("[JFrogCliDownloader] Lock held by another process, waiting... (" + - (elapsedTime / 1000) + "s elapsed)"); - Thread.sleep(LOCK_RETRY_INTERVAL_MS); - elapsedTime = System.currentTimeMillis() - startTime; - } - - throw new IOException("Timeout waiting for installation lock after " + - (LOCK_TIMEOUT_MS / 1000) + " seconds. Another installation may be in progress at: " + - lockFile.getAbsolutePath()); - } - - /** - * Performs the actual download operation while holding the installation lock. - * This method contains the core download logic that was previously in invoke(). + * Performs the actual download operation for fresh installations. * * @param toolLocation The target directory for CLI installation * @throws IOException If download fails @@ -476,7 +348,7 @@ private boolean performAtomicDownloadForUpgrade(ArtifactoryManager manager, Stri } catch (IOException e) { // For upgrade failures, check if we can fall back to existing - if (isFileLockingError(e) && isExistingCliValid(existingCli)) { + if (isWindowsTarget() && isFileLockingError(e) && isExistingCliValid(existingCli)) { log.getLogger().println("[JFrogCliDownloader] WARNING: Upgrade failed due to file locking. " + "Using existing CLI version. Upgrade will be attempted in next build."); cleanupTempFile(temporaryDownloadFile); @@ -505,6 +377,9 @@ private boolean tryMoveFileForUpgrade(File source, File target) throws IOExcepti return true; } catch (IOException e) { if (isFileLockingError(e)) { + if (!isWindowsTarget()) { + throw e; + } log.getLogger().println("[JFrogCliDownloader] Target file is locked: " + e.getMessage()); return false; } @@ -519,16 +394,34 @@ private boolean tryMoveFileForUpgrade(File source, File target) throws IOExcepti * @return true if this is a file locking error */ private boolean isFileLockingError(Exception e) { - String errorMsg = e.getMessage(); - if (errorMsg == null) { + Throwable current = e; + while (current != null) { + if (current instanceof AccessDeniedException) { + return true; + } + if (current instanceof FileSystemException) { + String reason = ((FileSystemException) current).getReason(); + if (containsLockingMessage(reason)) { + return true; + } + } + if (containsLockingMessage(current.getMessage())) { + return true; + } + current = current.getCause(); + } + return false; + } + + private boolean containsLockingMessage(String message) { + if (message == null) { return false; } - - return errorMsg.contains("being used by another process") || - errorMsg.contains("Access is denied") || - errorMsg.contains("cannot access the file") || - errorMsg.contains("locked") || - errorMsg.contains("in use"); + return message.contains("being used by another process") || + message.contains("Access is denied") || + message.contains("cannot access the file") || + message.contains("locked") || + message.contains("in use"); } /** @@ -615,8 +508,14 @@ private static void createSha256File(File toolLocation, String artifactorySha256 * @param artifactorySha256 - sha256 of the expected file in artifactory. */ private static boolean shouldDownloadTool(File toolLocation, String artifactorySha256) throws IOException { - // In case no sha256 was provided (for example when the customer blocks headers) download the tool. + // In case no sha256 was provided (for example when the users blocks headers), + // fall back to local checksum presence to avoid unnecessary repeated downloads. if (artifactorySha256.isEmpty()) { + Path path = toolLocation.toPath().resolve(SHA256_FILE_NAME); + if (Files.exists(path)) { + String fileContent = new String(Files.readAllBytes(path), StandardCharsets.UTF_8); + return StringUtils.isBlank(fileContent); + } return true; } // Looking for the sha256 file in the tool directory. @@ -639,7 +538,9 @@ private static boolean shouldDownloadTool(File toolLocation, String artifactoryS private static String getArtifactSha256(ArtifactoryManager manager, String cliUrlSuffix) throws IOException { Header[] headers = manager.downloadHeaders(cliUrlSuffix); for (Header header : headers) { - if (header.getName().equalsIgnoreCase(SHA256_HEADER_NAME)) { + String headerName = header.getName(); + if (headerName.equalsIgnoreCase(SHA256_HEADER_NAME) || + headerName.equalsIgnoreCase("X-Artifactory-Checksum-Sha256")) { return header.getValue(); } } @@ -668,4 +569,11 @@ private String getStageNameFromThread() { return "unknown"; } } + + /** + * Determine whether the target CLI binary is Windows. + */ + private boolean isWindowsTarget() { + return binaryName != null && binaryName.toLowerCase().endsWith(".exe"); + } } From 5dbe4440fca784ce9e921a2b7715661ae4d75f6b Mon Sep 17 00:00:00 2001 From: Bhanu Reddy Date: Thu, 19 Feb 2026 02:43:05 +0530 Subject: [PATCH 06/18] Removed writing sha256 to file when it is empty --- .../plugins/jfrog/BinaryInstaller.java | 28 ++----------------- .../jfrog/callables/JFrogCliDownloader.java | 11 +++----- 2 files changed, 6 insertions(+), 33 deletions(-) diff --git a/src/main/java/io/jenkins/plugins/jfrog/BinaryInstaller.java b/src/main/java/io/jenkins/plugins/jfrog/BinaryInstaller.java index 134dba7f..ae7a823c 100644 --- a/src/main/java/io/jenkins/plugins/jfrog/BinaryInstaller.java +++ b/src/main/java/io/jenkins/plugins/jfrog/BinaryInstaller.java @@ -239,14 +239,8 @@ private static boolean isCorrectVersion(FilePath toolLocation, JFrogPlatformInst // Get expected SHA256 from Artifactory String expectedSha256 = getArtifactSha256(manager, cliUrlSuffix); if (expectedSha256.isEmpty()) { - log.getLogger().println("[BinaryInstaller] No SHA256 available from server"); - boolean hasLocalChecksum = hasLocalChecksumFile(toolLocation); - if (hasLocalChecksum) { - log.getLogger().println("[BinaryInstaller] Existing CLI and local checksum found, skipping upgrade"); - return true; - } - log.getLogger().println("[BinaryInstaller] Local checksum missing, proceeding with download check"); - return false; + log.getLogger().println("[BinaryInstaller] No SHA256 available from server, reusing existing valid CLI"); + return true; } // Check local SHA256 file @@ -270,24 +264,6 @@ public Boolean invoke(File f, VirtualChannel channel) throws IOException, Interr } } - /** - * Check if local checksum file exists and is non-empty. - */ - private static boolean hasLocalChecksumFile(FilePath toolLocation) { - try { - return toolLocation.act(new MasterToSlaveFileCallable() { - @Override - public Boolean invoke(File f, VirtualChannel channel) throws IOException, InterruptedException { - File sha256File = new File(f, "sha256"); - return sha256File.exists() && sha256File.length() > 0; - } - }); - } catch (Exception e) { - LOGGER.fine("Failed to verify local checksum file: " + e.getMessage()); - return false; - } - } - /** * Get SHA256 hash from Artifactory headers (same logic as in JFrogCliDownloader) */ diff --git a/src/main/java/io/jenkins/plugins/jfrog/callables/JFrogCliDownloader.java b/src/main/java/io/jenkins/plugins/jfrog/callables/JFrogCliDownloader.java index f18e1f66..45ee7b18 100644 --- a/src/main/java/io/jenkins/plugins/jfrog/callables/JFrogCliDownloader.java +++ b/src/main/java/io/jenkins/plugins/jfrog/callables/JFrogCliDownloader.java @@ -495,6 +495,9 @@ private void cleanupTempFile(File tempFile) { } private static void createSha256File(File toolLocation, String artifactorySha256) throws IOException { + if (StringUtils.isBlank(artifactorySha256)) { + return; + } File file = new File(toolLocation, SHA256_FILE_NAME); Files.write(file.toPath(), artifactorySha256.getBytes(StandardCharsets.UTF_8)); } @@ -508,14 +511,8 @@ private static void createSha256File(File toolLocation, String artifactorySha256 * @param artifactorySha256 - sha256 of the expected file in artifactory. */ private static boolean shouldDownloadTool(File toolLocation, String artifactorySha256) throws IOException { - // In case no sha256 was provided (for example when the users blocks headers), - // fall back to local checksum presence to avoid unnecessary repeated downloads. + // In case no sha256 was provided (for example when the users blocks headers) download the tool. if (artifactorySha256.isEmpty()) { - Path path = toolLocation.toPath().resolve(SHA256_FILE_NAME); - if (Files.exists(path)) { - String fileContent = new String(Files.readAllBytes(path), StandardCharsets.UTF_8); - return StringUtils.isBlank(fileContent); - } return true; } // Looking for the sha256 file in the tool directory. From 2e25b6196674720b6929c130e15346c4666b77fa Mon Sep 17 00:00:00 2001 From: Bhanu Reddy Date: Thu, 19 Feb 2026 11:36:57 +0530 Subject: [PATCH 07/18] Removed version from filelock --- .../jenkins/plugins/jfrog/BinaryInstaller.java | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/main/java/io/jenkins/plugins/jfrog/BinaryInstaller.java b/src/main/java/io/jenkins/plugins/jfrog/BinaryInstaller.java index ae7a823c..081274e1 100644 --- a/src/main/java/io/jenkins/plugins/jfrog/BinaryInstaller.java +++ b/src/main/java/io/jenkins/plugins/jfrog/BinaryInstaller.java @@ -94,7 +94,8 @@ public static FilePath performJfrogCliInstallation(FilePath toolLocation, TaskLi JFrogPlatformInstance instance, String repository, String binaryName) throws IOException, InterruptedException { - // Create unique lock key for this node + installation path + version combination + // Create unique lock key for this node + installation path combination. + // Version is intentionally excluded to serialize all writes to the same binary path. String lockKey = createLockKey(toolLocation, binaryName, version); // Get or create synchronization lock for this specific installation location @@ -138,21 +139,20 @@ public static FilePath performJfrogCliInstallation(FilePath toolLocation, TaskLi } /** - * Creates a unique lock key for the installation location and version. - * Including version in the key ensures different versions can be installed concurrently - * and prevents race conditions during version upgrades. - * + * Creates a unique lock key for the installation location. + * Version is excluded so all operations targeting the same binary path are serialized. + * * @param toolLocation Installation directory * @param binaryName Binary file name - * @param version CLI version being installed + * @param version CLI version being installed (unused, kept for signature compatibility) * @return Unique lock key string */ private static String createLockKey(FilePath toolLocation, String binaryName, String version) { try { - return toolLocation.getRemote() + "/" + binaryName + "/" + version; + return toolLocation.getRemote() + "/" + binaryName; } catch (Exception e) { // Fallback to a simpler key if remote path access fails - return "unknown-tool-location/" + binaryName + "/" + version; + return "unknown-tool-location/" + binaryName; } } From f89168e1c4f9b858e45d1b0c1562642b7c7adc40 Mon Sep 17 00:00:00 2001 From: JFrog Pipelines Step Date: Tue, 10 Feb 2026 09:06:15 +0000 Subject: [PATCH 08/18] [jfrog-release] Release version 1.6.0 [skipRun] --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 87002519..b00504f0 100644 --- a/pom.xml +++ b/pom.xml @@ -15,7 +15,7 @@ io.jenkins.plugins jfrog - 1.5.x-SNAPSHOT + 1.6.0 hpi JFrog Plugin From 59dd72ff87795deb981b94de8f26116501fe210a Mon Sep 17 00:00:00 2001 From: JFrog Pipelines Step Date: Tue, 10 Feb 2026 09:10:48 +0000 Subject: [PATCH 09/18] [jfrog-release] Next development version [skipRun] --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index b00504f0..a346a404 100644 --- a/pom.xml +++ b/pom.xml @@ -15,7 +15,7 @@ io.jenkins.plugins jfrog - 1.6.0 + 1.6.x-SNAPSHOT hpi JFrog Plugin From 8923c41645314972a61134e9f6230788b37b96bf Mon Sep 17 00:00:00 2001 From: agrasth Date: Fri, 13 Feb 2026 18:56:15 +0530 Subject: [PATCH 10/18] Updated jfrog pipeline's integration --- .jfrog-pipelines/pipelines.release.yml | 5 +++-- .jfrog-pipelines/pipelines.resources.yml | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.jfrog-pipelines/pipelines.release.yml b/.jfrog-pipelines/pipelines.release.yml index 8d77995c..ec692b27 100644 --- a/.jfrog-pipelines/pipelines.release.yml +++ b/.jfrog-pipelines/pipelines.release.yml @@ -19,7 +19,8 @@ pipelines: inputResources: - name: jenkinsJFrogReleaseGit integrations: - - name: il_automation + - name: jenkins_automation # Used for git operations for jenkinsci org + - name: il_automation # Available for other operations if needed - name: ecosys_entplus_deployer - name: jenkins_artifactory_jfrog_plugin execution: @@ -36,7 +37,7 @@ pipelines: # Configure git - git checkout main - git remote set-url origin https://$int_il_automation_token@github.com/jfrog/jenkins-jfrog-plugin.git - - git remote add upstream https://$int_il_automation_token@github.com/jenkinsci/jfrog-plugin.git + - git remote add upstream https://$int_jenkins_automation_token@github.com/jenkinsci/jfrog-plugin.git # Make sure versions provided - echo "Checking variables" diff --git a/.jfrog-pipelines/pipelines.resources.yml b/.jfrog-pipelines/pipelines.resources.yml index b26d8019..cd62a905 100644 --- a/.jfrog-pipelines/pipelines.resources.yml +++ b/.jfrog-pipelines/pipelines.resources.yml @@ -3,7 +3,7 @@ resources: type: GitRepo configuration: path: jfrog/jenkins-jfrog-plugin - gitProvider: il_automation + gitProvider: il_automation # Uses il_automation for jfrog org access branches: include: main @@ -11,7 +11,7 @@ resources: type: GitRepo configuration: path: jfrog/jenkins-jfrog-plugin - gitProvider: il_automation + gitProvider: il_automation # Uses il_automation for jfrog org access buildOn: commit: false branches: From de621d76b51f1691b20c5117dde9c8243c1ef530 Mon Sep 17 00:00:00 2001 From: Naveen Kumar Date: Thu, 19 Feb 2026 17:29:04 +0530 Subject: [PATCH 11/18] =?UTF-8?q?Updated=20encryption=20key=20file=20path?= =?UTF-8?q?=20to=20jfrog-home=20dir=20instead=20of=20jenkins=E2=80=A6=20(#?= =?UTF-8?q?141)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plugins/jfrog/CliEnvConfigurator.java | 21 ++++++++---- .../java/io/jenkins/plugins/jfrog/JfStep.java | 2 +- .../jenkins/plugins/jfrog/JfrogBuilder.java | 2 +- .../actions/JFrogCliConfigEncryption.java | 10 +++--- .../jfrog/CliEnvConfiguratorProxyTest.java | 14 ++++---- .../plugins/jfrog/CliEnvConfiguratorTest.java | 32 ++++++++++++------- 6 files changed, 50 insertions(+), 31 deletions(-) diff --git a/src/main/java/io/jenkins/plugins/jfrog/CliEnvConfigurator.java b/src/main/java/io/jenkins/plugins/jfrog/CliEnvConfigurator.java index 243d003a..6cf4cdc9 100644 --- a/src/main/java/io/jenkins/plugins/jfrog/CliEnvConfigurator.java +++ b/src/main/java/io/jenkins/plugins/jfrog/CliEnvConfigurator.java @@ -1,10 +1,13 @@ package io.jenkins.plugins.jfrog; import hudson.EnvVars; +import hudson.FilePath; import io.jenkins.plugins.jfrog.actions.JFrogCliConfigEncryption; import io.jenkins.plugins.jfrog.configuration.JenkinsProxyConfiguration; import org.apache.commons.lang3.StringUtils; +import java.io.IOException; + /** * Configures JFrog CLI environment variables for the job. * @@ -26,26 +29,30 @@ public class CliEnvConfigurator { * Configure the JFrog CLI environment variables, according to the input job's env. * * @param env - Job's environment variables - * @param jfrogHomeTempDir - Calculated JFrog CLI home dir + * @param jfrogHomeTempDir - Calculated JFrog CLI home dir (FilePath on the agent) * @param encryptionKey - Random encryption key to encrypt the CLI config + * @throws IOException if the encryption key file cannot be written + * @throws InterruptedException if the operation is interrupted */ - static void configureCliEnv(EnvVars env, String jfrogHomeTempDir, JFrogCliConfigEncryption encryptionKey) { + static void configureCliEnv(EnvVars env, FilePath jfrogHomeTempDir, JFrogCliConfigEncryption encryptionKey) throws IOException, InterruptedException { // Setting Jenkins job name as the default build-info name env.putIfAbsent(JFROG_CLI_BUILD_NAME, env.get("JOB_NAME")); // Setting Jenkins build number as the default build-info number env.putIfAbsent(JFROG_CLI_BUILD_NUMBER, env.get("BUILD_NUMBER")); // Setting the specific build URL env.putIfAbsent(JFROG_CLI_BUILD_URL, env.get("BUILD_URL")); - // Set up a temporary Jfrog CLI home directory for a specific run - env.put(JFROG_CLI_HOME_DIR, jfrogHomeTempDir); + // Set up a temporary Jfrog CLI home directory for a specific run. + // Use getRemote() to get the path as seen by the agent. + env.put(JFROG_CLI_HOME_DIR, jfrogHomeTempDir.getRemote()); if (StringUtils.isAllBlank(env.get(HTTP_PROXY_ENV), env.get(HTTPS_PROXY_ENV))) { // Set up HTTP/S proxy setupProxy(env); } if (encryptionKey.shouldEncrypt()) { - // Set up a random encryption key to make sure no raw text secrets are stored in the file system - // getKey() returns the actual 32-character encryption key content, not the file path - env.putIfAbsent(JFROG_CLI_ENCRYPTION_KEY, encryptionKey.getKey()); + // Write the encryption key file on the agent (not controller) using FilePath. + // This ensures the file exists where the JFrog CLI runs (Docker/remote agent). + String keyFilePath = encryptionKey.writeKeyFile(jfrogHomeTempDir); + env.putIfAbsent(JFROG_CLI_ENCRYPTION_KEY, keyFilePath); } } diff --git a/src/main/java/io/jenkins/plugins/jfrog/JfStep.java b/src/main/java/io/jenkins/plugins/jfrog/JfStep.java index 636cef40..c36b72e4 100644 --- a/src/main/java/io/jenkins/plugins/jfrog/JfStep.java +++ b/src/main/java/io/jenkins/plugins/jfrog/JfStep.java @@ -196,7 +196,7 @@ public Launcher.ProcStarter setupJFrogEnvironment(Run run, EnvVars env, La run.addAction(jfrogCliConfigEncryption); } FilePath jfrogHomeTempDir = Utils.createAndGetJfrogCliHomeTempDir(workspace, String.valueOf(run.getNumber())); - CliEnvConfigurator.configureCliEnv(env, jfrogHomeTempDir.getRemote(), jfrogCliConfigEncryption); + CliEnvConfigurator.configureCliEnv(env, jfrogHomeTempDir, jfrogCliConfigEncryption); Launcher.ProcStarter jfLauncher = launcher.launch().envs(env).pwd(workspace).stdout(listener); // Configure all servers, skip if all server ids have already been configured. if (shouldConfig(jfrogHomeTempDir)) { diff --git a/src/main/java/io/jenkins/plugins/jfrog/JfrogBuilder.java b/src/main/java/io/jenkins/plugins/jfrog/JfrogBuilder.java index 7f3e9656..d407cc7f 100644 --- a/src/main/java/io/jenkins/plugins/jfrog/JfrogBuilder.java +++ b/src/main/java/io/jenkins/plugins/jfrog/JfrogBuilder.java @@ -247,7 +247,7 @@ private Launcher.ProcStarter setupJFrogEnvironment( } FilePath jfrogHomeTempDir = Utils.createAndGetJfrogCliHomeTempDir(workspace, String.valueOf(run.getNumber())); - CliEnvConfigurator.configureCliEnv(env, jfrogHomeTempDir.getRemote(), jfrogCliConfigEncryption); + CliEnvConfigurator.configureCliEnv(env, jfrogHomeTempDir, jfrogCliConfigEncryption); Launcher.ProcStarter jfLauncher = launcher.launch().envs(env).pwd(workspace).stdout(cliOutputListener); // Configure all servers, skip if all server ids have already been configured. diff --git a/src/main/java/io/jenkins/plugins/jfrog/actions/JFrogCliConfigEncryption.java b/src/main/java/io/jenkins/plugins/jfrog/actions/JFrogCliConfigEncryption.java index acecbc4b..70f86d25 100644 --- a/src/main/java/io/jenkins/plugins/jfrog/actions/JFrogCliConfigEncryption.java +++ b/src/main/java/io/jenkins/plugins/jfrog/actions/JFrogCliConfigEncryption.java @@ -1,13 +1,11 @@ package io.jenkins.plugins.jfrog.actions; import hudson.EnvVars; +import hudson.FilePath; import hudson.model.Action; import java.io.IOException; import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; import java.util.UUID; import static io.jenkins.plugins.jfrog.CliEnvConfigurator.JFROG_CLI_HOME_DIR; @@ -19,8 +17,10 @@ **/ public class JFrogCliConfigEncryption implements Action { private boolean shouldEncrypt; - private String keyOrPath; - private String keyContent; + // The encryption key content (32 characters) + private String key; + // The path to the key file (set when writeKeyFile is called) + private String keyFilePath; public JFrogCliConfigEncryption(EnvVars env) { if (env.containsKey(JFROG_CLI_HOME_DIR)) { diff --git a/src/test/java/io/jenkins/plugins/jfrog/CliEnvConfiguratorProxyTest.java b/src/test/java/io/jenkins/plugins/jfrog/CliEnvConfiguratorProxyTest.java index 8cfeca91..982b3e60 100644 --- a/src/test/java/io/jenkins/plugins/jfrog/CliEnvConfiguratorProxyTest.java +++ b/src/test/java/io/jenkins/plugins/jfrog/CliEnvConfiguratorProxyTest.java @@ -4,6 +4,8 @@ import org.junit.Before; import org.junit.Test; +import java.io.IOException; + import static io.jenkins.plugins.jfrog.CliEnvConfigurator.*; import static org.junit.Assert.assertNull; @@ -22,7 +24,7 @@ public void setUp() { } @Test - public void configureCliEnvHttpProxyTest() { + public void configureCliEnvHttpProxyTest() throws IOException, InterruptedException { proxyConfiguration.port = 80; invokeConfigureCliEnv(); assertEnv(envVars, HTTP_PROXY_ENV, "http://acme.proxy.io:80"); @@ -31,7 +33,7 @@ public void configureCliEnvHttpProxyTest() { } @Test - public void configureCliEnvHttpsProxyTest() { + public void configureCliEnvHttpsProxyTest() throws IOException, InterruptedException { proxyConfiguration.port = 443; invokeConfigureCliEnv(); assertEnv(envVars, HTTP_PROXY_ENV, "https://acme.proxy.io:443"); @@ -40,7 +42,7 @@ public void configureCliEnvHttpsProxyTest() { } @Test - public void configureCliEnvHttpProxyAuthTest() { + public void configureCliEnvHttpProxyAuthTest() throws IOException, InterruptedException { proxyConfiguration.port = 80; proxyConfiguration.username = "andor"; proxyConfiguration.password = "RogueOne"; @@ -51,7 +53,7 @@ public void configureCliEnvHttpProxyAuthTest() { } @Test - public void configureCliEnvHttpsProxyAuthTest() { + public void configureCliEnvHttpsProxyAuthTest() throws IOException, InterruptedException { proxyConfiguration.port = 443; proxyConfiguration.username = "andor"; proxyConfiguration.password = "RogueOne"; @@ -62,14 +64,14 @@ public void configureCliEnvHttpsProxyAuthTest() { } @Test - public void configureCliEnvNoOverrideHttpTest() { + public void configureCliEnvNoOverrideHttpTest() throws IOException, InterruptedException { envVars.put(HTTP_PROXY_ENV, "http://acme2.proxy.io:777"); invokeConfigureCliEnv(); assertEnv(envVars, HTTP_PROXY_ENV, "http://acme2.proxy.io:777"); } @Test - public void configureCliEnvNoOverrideTest() { + public void configureCliEnvNoOverrideTest() throws IOException, InterruptedException { envVars.put(HTTP_PROXY_ENV, "http://acme2.proxy.io:80"); envVars.put(HTTPS_PROXY_ENV, "http://acme2.proxy.io:443"); invokeConfigureCliEnv(); diff --git a/src/test/java/io/jenkins/plugins/jfrog/CliEnvConfiguratorTest.java b/src/test/java/io/jenkins/plugins/jfrog/CliEnvConfiguratorTest.java index 0a41c9c6..0bc198e8 100644 --- a/src/test/java/io/jenkins/plugins/jfrog/CliEnvConfiguratorTest.java +++ b/src/test/java/io/jenkins/plugins/jfrog/CliEnvConfiguratorTest.java @@ -1,14 +1,19 @@ package io.jenkins.plugins.jfrog; import hudson.EnvVars; +import hudson.FilePath; import io.jenkins.plugins.jfrog.actions.JFrogCliConfigEncryption; import io.jenkins.plugins.jfrog.configuration.JenkinsProxyConfiguration; import jenkins.model.Jenkins; import org.junit.Before; import org.junit.Rule; import org.junit.Test; +import org.junit.rules.TemporaryFolder; import org.jvnet.hudson.test.JenkinsRule; +import java.io.File; +import java.io.IOException; + import static io.jenkins.plugins.jfrog.CliEnvConfigurator.*; import static org.junit.Assert.*; @@ -19,6 +24,8 @@ public class CliEnvConfiguratorTest { @Rule public JenkinsRule jenkinsRule = new JenkinsRule(); + @Rule + public TemporaryFolder tempFolder = new TemporaryFolder(); JenkinsProxyConfiguration proxyConfiguration; EnvVars envVars; @@ -31,31 +38,33 @@ public void setUp() { } @Test - public void configureCliEnvBasicTest() { - invokeConfigureCliEnv("a/b/c", new JFrogCliConfigEncryption(envVars)); + public void configureCliEnvBasicTest() throws IOException, InterruptedException { + File jfrogHomeDir = tempFolder.newFolder("jfrog-home"); + FilePath jfrogHomeTempDir = new FilePath(jfrogHomeDir); + invokeConfigureCliEnv(jfrogHomeTempDir, new JFrogCliConfigEncryption(envVars)); assertEnv(envVars, JFROG_CLI_BUILD_NAME, "buildName"); assertEnv(envVars, JFROG_CLI_BUILD_NUMBER, "1"); assertEnv(envVars, JFROG_CLI_BUILD_URL, "https://acme.jenkins.io"); - assertEnv(envVars, JFROG_CLI_HOME_DIR, "a/b/c"); + assertEnv(envVars, JFROG_CLI_HOME_DIR, jfrogHomeDir.getAbsolutePath()); } @Test - public void configEncryptionTest() { + public void configEncryptionTest() throws IOException, InterruptedException { JFrogCliConfigEncryption configEncryption = new JFrogCliConfigEncryption(envVars); assertTrue(configEncryption.shouldEncrypt()); assertEquals(32, configEncryption.getKey().length()); invokeConfigureCliEnv("a/b/c", configEncryption); - // The environment variable should contain the actual 32-character encryption key, not the file path - assertEnv(envVars, JFROG_CLI_ENCRYPTION_KEY, configEncryption.getKey()); + assertEnv(envVars, JFROG_CLI_ENCRYPTION_KEY, configEncryption.getKeyOrFilePath()); } @Test - public void configEncryptionWithHomeDirTest() { + public void configEncryptionWithHomeDirTest() throws IOException, InterruptedException { // Config JFROG_CLI_HOME_DIR to disable key encryption envVars.put(JFROG_CLI_HOME_DIR, "/a/b/c"); JFrogCliConfigEncryption configEncryption = new JFrogCliConfigEncryption(envVars); - invokeConfigureCliEnv("", configEncryption); + File emptyDir = tempFolder.newFolder("empty"); + invokeConfigureCliEnv(new FilePath(emptyDir), configEncryption); assertFalse(configEncryption.shouldEncrypt()); assertFalse(envVars.containsKey(JFROG_CLI_ENCRYPTION_KEY)); @@ -65,11 +74,12 @@ void assertEnv(EnvVars envVars, String key, String expectedValue) { assertEquals(expectedValue, envVars.get(key)); } - void invokeConfigureCliEnv() { - this.invokeConfigureCliEnv("", new JFrogCliConfigEncryption(envVars)); + void invokeConfigureCliEnv() throws IOException, InterruptedException { + File emptyDir = tempFolder.newFolder("default"); + this.invokeConfigureCliEnv(new FilePath(emptyDir), new JFrogCliConfigEncryption(envVars)); } - void invokeConfigureCliEnv(String jfrogHomeTempDir, JFrogCliConfigEncryption configEncryption) { + void invokeConfigureCliEnv(FilePath jfrogHomeTempDir, JFrogCliConfigEncryption configEncryption) throws IOException, InterruptedException { setProxyConfiguration(); configureCliEnv(envVars, jfrogHomeTempDir, configEncryption); } From e8644709211b41b09c8f002e9e45701fd0408eba Mon Sep 17 00:00:00 2001 From: JFrog Pipelines Step Date: Thu, 19 Feb 2026 18:19:10 +0000 Subject: [PATCH 12/18] [jfrog-release] Release version 1.6.1 [skipRun] --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index a346a404..5ec642e2 100644 --- a/pom.xml +++ b/pom.xml @@ -15,7 +15,7 @@ io.jenkins.plugins jfrog - 1.6.x-SNAPSHOT + 1.6.1 hpi JFrog Plugin From e27ff8c895f13e10d5a9c016e31bf1af8be32a2a Mon Sep 17 00:00:00 2001 From: JFrog Pipelines Step Date: Thu, 19 Feb 2026 18:22:52 +0000 Subject: [PATCH 13/18] [jfrog-release] Next development version [skipRun] --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 5ec642e2..a346a404 100644 --- a/pom.xml +++ b/pom.xml @@ -15,7 +15,7 @@ io.jenkins.plugins jfrog - 1.6.1 + 1.6.x-SNAPSHOT hpi JFrog Plugin From 001e8004987deba7e599737e5bb974f6d353a65b Mon Sep 17 00:00:00 2001 From: Naveen Kumar Date: Tue, 24 Feb 2026 13:02:16 +0530 Subject: [PATCH 14/18] Fix/rteco 900 jfrog jenkins plugin latest version is not working in multi agent pipeline (#142) --- .../plugins/jfrog/CliEnvConfigurator.java | 7 +- .../actions/JFrogCliConfigEncryption.java | 70 +++++++++---------- 2 files changed, 37 insertions(+), 40 deletions(-) diff --git a/src/main/java/io/jenkins/plugins/jfrog/CliEnvConfigurator.java b/src/main/java/io/jenkins/plugins/jfrog/CliEnvConfigurator.java index 6cf4cdc9..afde3ae8 100644 --- a/src/main/java/io/jenkins/plugins/jfrog/CliEnvConfigurator.java +++ b/src/main/java/io/jenkins/plugins/jfrog/CliEnvConfigurator.java @@ -49,10 +49,11 @@ static void configureCliEnv(EnvVars env, FilePath jfrogHomeTempDir, JFrogCliConf setupProxy(env); } if (encryptionKey.shouldEncrypt()) { - // Write the encryption key file on the agent (not controller) using FilePath. - // This ensures the file exists where the JFrog CLI runs (Docker/remote agent). + // Write the encryption key file on the current agent using FilePath. + // Always overwrite (not putIfAbsent) because in multi-agent pipelines the env + // var may still hold the previous agent's path, which doesn't exist on this agent. String keyFilePath = encryptionKey.writeKeyFile(jfrogHomeTempDir); - env.putIfAbsent(JFROG_CLI_ENCRYPTION_KEY, keyFilePath); + env.put(JFROG_CLI_ENCRYPTION_KEY, keyFilePath); } } diff --git a/src/main/java/io/jenkins/plugins/jfrog/actions/JFrogCliConfigEncryption.java b/src/main/java/io/jenkins/plugins/jfrog/actions/JFrogCliConfigEncryption.java index 70f86d25..90c972bd 100644 --- a/src/main/java/io/jenkins/plugins/jfrog/actions/JFrogCliConfigEncryption.java +++ b/src/main/java/io/jenkins/plugins/jfrog/actions/JFrogCliConfigEncryption.java @@ -30,49 +30,45 @@ public JFrogCliConfigEncryption(EnvVars env) { } this.shouldEncrypt = true; // UUID is a cryptographically strong encryption key. Without the dashes, it contains exactly 32 characters. - String workspacePath = env.get("WORKSPACE"); - if (workspacePath == null || workspacePath.isEmpty()) { - workspacePath = System.getProperty("java.io.tmpdir"); - } - Path encryptionDir = Paths.get(workspacePath, ".jfrog", "encryption"); - try { - Files.createDirectories(encryptionDir); - String fileName = UUID.randomUUID().toString() + ".key"; - Path keyFilePath = encryptionDir.resolve(fileName); - String encryptionKeyContent = UUID.randomUUID().toString().replaceAll("-", ""); - Files.write(keyFilePath, encryptionKeyContent.getBytes(StandardCharsets.UTF_8)); - this.keyOrPath = keyFilePath.toString(); - this.keyContent = encryptionKeyContent; - } catch (IOException e) { - throw new RuntimeException(e); + this.key = UUID.randomUUID().toString().replaceAll("-", ""); + } + + /** + * Writes the encryption key to a file in the specified directory on the agent. + * Uses FilePath to ensure the file is written on the remote agent, not the controller. + *

+ * The key file is always written fresh to the given jfrogHomeTempDir. In multi-agent + * pipelines each agent has its own filesystem, so the file must be written locally on + * every agent where the JFrog CLI runs. The key content stays the same across agents. + * + * @param jfrogHomeTempDir - The JFrog CLI home temp directory (FilePath on the agent) + * @return The path to the key file (as seen by the agent) + * @throws IOException if the file cannot be written + * @throws InterruptedException if the operation is interrupted + */ + public String writeKeyFile(FilePath jfrogHomeTempDir) throws IOException, InterruptedException { + if (this.key == null || this.key.isEmpty()) { + return null; } + // Always write the key file on the current agent's filesystem. + // Do NOT cache/reuse keyFilePath: in multi-agent pipelines each agent has its own + // filesystem, so returning a previously cached path would point to a different + // agent's file which does not exist on the current agent. + FilePath encryptionDir = jfrogHomeTempDir.child("encryption"); + encryptionDir.mkdirs(); + String fileName = UUID.randomUUID().toString() + ".key"; + FilePath keyFile = encryptionDir.child(fileName); + keyFile.write(this.key, StandardCharsets.UTF_8.name()); + this.keyFilePath = keyFile.getRemote(); + return this.keyFilePath; } public String getKey() { - if (this.keyContent != null && !this.keyContent.isEmpty()) { - return this.keyContent; - } - if (this.keyOrPath == null || this.keyOrPath.isEmpty()) { - throw new IllegalStateException("Encryption key is not initialized"); - } - try { - byte[] keyBytes = Files.readAllBytes(Paths.get(this.keyOrPath)); - String key = new String(keyBytes, StandardCharsets.UTF_8).trim(); - if (key.isEmpty()) { - throw new IllegalStateException("Encryption key file is empty: " + this.keyOrPath); - } - this.keyContent = key; - return key; - } catch (IOException e) { - throw new IllegalStateException("Failed reading encryption key file: " + this.keyOrPath, e); - } + return this.key; } - public String getKeyOrFilePath() { - if (this.keyOrPath == null || this.keyOrPath.isEmpty()) { - return null; - } - return this.keyOrPath; + public String getKeyFilePath() { + return this.keyFilePath; } public boolean shouldEncrypt() { From 4f9d6ccef5e628a532e7419621e1a018a8e0a21c Mon Sep 17 00:00:00 2001 From: Bhanu Reddy Date: Sun, 15 Mar 2026 13:30:43 +0530 Subject: [PATCH 15/18] Fix parallel pipeline race conditions in CLI install and config encryption --- e2e/docker-compose.yml | 36 +++ e2e/setup.sh | 38 +++ .../plugins/jfrog/BinaryInstaller.java | 273 +++++++++--------- .../java/io/jenkins/plugins/jfrog/JfStep.java | 17 +- .../jfrog/JfrogBuildInfoPublisher.java | 10 +- .../plugins/jfrog/JfrogCliWrapper.java | 35 +-- .../jfrog/callables/JFrogCliDownloader.java | 235 ++++++--------- .../jfrog/integration/FreestyleJobITest.java | 133 +++++++++ .../integration/ParallelInstallITest.java | 72 +++++ .../jfrog/integration/PipelineTestBase.java | 6 +- .../pipelines/parallel_install.pipeline | 23 ++ .../parallel_install_two_tools.pipeline | 18 ++ 12 files changed, 588 insertions(+), 308 deletions(-) create mode 100644 e2e/docker-compose.yml create mode 100755 e2e/setup.sh create mode 100644 src/test/java/io/jenkins/plugins/jfrog/integration/FreestyleJobITest.java create mode 100644 src/test/java/io/jenkins/plugins/jfrog/integration/ParallelInstallITest.java create mode 100644 src/test/resources/integration/pipelines/parallel_install.pipeline create mode 100644 src/test/resources/integration/pipelines/parallel_install_two_tools.pipeline diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml new file mode 100644 index 00000000..9ecf70a8 --- /dev/null +++ b/e2e/docker-compose.yml @@ -0,0 +1,36 @@ +services: + + postgres: + image: docker.jfrog.io/postgres:14.5 + ports: + - "5432:5432" + environment: + POSTGRES_DB: artifactory + POSTGRES_USER: artifactory + POSTGRES_PASSWORD: password + + artifactory: + container_name: artifactory-jenkins-plugin + image: docker.jfrog.io/jfrog/artifactory-pro:latest + ports: + - "8046:8046" + - "8081:8081" + - "8082:8082" + environment: + JF_SHARED_NODE_ID: node-01 + JF_SHARED_DATABASE_TYPE: postgresql + JF_SHARED_DATABASE_DRIVER: org.postgresql.Driver + JF_SHARED_DATABASE_URL: jdbc:postgresql://host.docker.internal:5432/artifactory + JF_SHARED_DATABASE_USERNAME: artifactory + JF_SHARED_DATABASE_PASSWORD: password + JF_SHARED_SECURITY_JOINKEY: cc949ef041b726994a225dc20e018f23 + JF_SHARED_SECURITY_MASTERKEY: b055e9d06d17a293f1934109d4c4b560 + JF_ROUTER_DEV_MAKE_INTERNAL_PORTS_PUBLIC: "true" + JF_SHARED_DATABASE_ALLOWNONPOSTGRESQL: "true" + depends_on: + - postgres + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8082/artifactory/api/system/ping"] + interval: 10s + timeout: 5s + retries: 30 diff --git a/e2e/setup.sh b/e2e/setup.sh new file mode 100755 index 00000000..374d746e --- /dev/null +++ b/e2e/setup.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Usage: source e2e/setup.sh +# Starts Artifactory and exports the required env vars for integration tests. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +echo "==> Starting Artifactory..." +docker compose -f "$SCRIPT_DIR/docker-compose.yml" up -d + +echo "==> Waiting for Artifactory to be healthy..." +for i in $(seq 1 30); do + if curl -sf http://localhost:8081/artifactory/api/system/ping > /dev/null 2>&1; then + echo "==> Artifactory is up!" + break + fi + echo " ...waiting ($i/30)" + sleep 5 +done + +# Default Artifactory OSS admin credentials +export JFROG_URL="http://localhost:8082" +export JFROG_USERNAME="admin" +export JFROG_PASSWORD="password" +export JFROG_ADMIN_TOKEN="" + +echo "" +echo "Environment variables exported:" +echo " JFROG_URL=$JFROG_URL" +echo " JFROG_USERNAME=$JFROG_USERNAME" +echo " JFROG_PASSWORD=***" +echo "" +echo "To run integration tests:" +echo " mvn verify -DskipITs=false -Dtest=NONE" +echo "" +echo "To run Jenkins locally with the plugin:" +echo " mvn hpi:run -Djenkins.version=2.440.3" diff --git a/src/main/java/io/jenkins/plugins/jfrog/BinaryInstaller.java b/src/main/java/io/jenkins/plugins/jfrog/BinaryInstaller.java index 081274e1..e5e7bd93 100644 --- a/src/main/java/io/jenkins/plugins/jfrog/BinaryInstaller.java +++ b/src/main/java/io/jenkins/plugins/jfrog/BinaryInstaller.java @@ -20,8 +20,9 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; -import java.util.Objects; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.ReentrantLock; import java.util.logging.Logger; import static org.jfrog.build.client.DownloadResponse.SHA256_HEADER_NAME; @@ -34,13 +35,32 @@ public abstract class BinaryInstaller extends ToolInstaller { private static final Logger LOGGER = Logger.getLogger(BinaryInstaller.class.getName()); - + + /** + * Environment variable that overrides the default lock-acquisition timeout (in minutes). + * Set this on the Jenkins controller when operating on slow networks where CLI downloads + * take longer than the default. + * + *

+     *   export JFROG_CLI_INSTALL_TIMEOUT_MINUTES=15
+     * 
+ */ + static final String INSTALL_TIMEOUT_ENV_VAR = "JFROG_CLI_INSTALL_TIMEOUT_MINUTES"; + private static final int DEFAULT_INSTALL_TIMEOUT_MINUTES = 5; + /** * Per-node synchronization locks for installation coordination. - * Key: Node name + tool installation path - * Value: Object used as synchronization lock + * Key: installation path + binary name (see {@link #createLockKey}) + * Value: ReentrantLock used to serialize installations to the same path + * + *

The map grows by one entry per unique (agent path, binary) combination encountered + * during the lifetime of the Jenkins JVM. In typical deployments the number of distinct + * tool installation paths is small and bounded, so the unbounded growth is acceptable. + * If the entry count ever becomes a concern, entries can be evicted after a successful + * installation without loss of correctness (a new lock will be created on the + * next access).

*/ - private static final ConcurrentHashMap NODE_INSTALLATION_LOCKS = new ConcurrentHashMap<>(); + private static final ConcurrentHashMap NODE_INSTALLATION_LOCKS = new ConcurrentHashMap<>(); protected BinaryInstaller(String label) { super(label); @@ -72,82 +92,127 @@ public String getId() { } /** - * Performs JFrog CLI installation with proper synchronization to ensure reliable installation. - * + * Performs JFrog CLI installation ensuring the pipeline always has a working binary. + * * INSTALLATION STRATEGY: - * 1. Create unique lock key per node + installation path - * 2. Use synchronized block to ensure only one installation per location at a time - * 3. Check if CLI already exists and is valid before downloading - * 4. Download using atomic file operations for reliability - * + * 1. Fast path (no lock): binary exists and sha256 check passes — return immediately. + * When the server does not return a sha256 header, the check treats "no hash" as + * "up-to-date", so a missing or empty sha256 file never causes a re-download loop. + * 2. Slow path (lock + download): acquire a ReentrantLock (configurable timeout, default + * 5 min) and call the downloader. Re-check version inside the lock in case a + * concurrent stage just finished. + * 3. Fallbacks: if the lock times out or the download fails, use any existing valid binary + * rather than failing the pipeline. Only throw when there is truly nothing to run. + * * @param toolLocation Target directory for CLI installation * @param log Task listener for logging progress - * @param version CLI version to install + * @param version CLI version to install (blank = latest) * @param instance JFrog platform instance for download * @param repository Repository containing the CLI binary * @param binaryName Name of the CLI binary file * @return FilePath of the installed CLI - * @throws IOException If installation fails + * @throws IOException If installation fails and no existing binary is available * @throws InterruptedException If installation is interrupted */ - public static FilePath performJfrogCliInstallation(FilePath toolLocation, TaskListener log, String version, - JFrogPlatformInstance instance, String repository, String binaryName) + public static FilePath performJfrogCliInstallation(FilePath toolLocation, TaskListener log, String version, + JFrogPlatformInstance instance, String repository, String binaryName) throws IOException, InterruptedException { - - // Create unique lock key for this node + installation path combination. - // Version is intentionally excluded to serialize all writes to the same binary path. - String lockKey = createLockKey(toolLocation, binaryName, version); - - // Get or create synchronization lock for this specific installation location - Object installationLock = NODE_INSTALLATION_LOCKS.computeIfAbsent(lockKey, k -> new Object()); - - log.getLogger().println("[BinaryInstaller] Acquiring installation lock for: " + lockKey); - - // Synchronize on the specific installation location to ensure coordinated installation - synchronized (installationLock) { - log.getLogger().println("[BinaryInstaller] Lock acquired, proceeding with installation"); - + + FilePath cliPath = toolLocation.child(binaryName); + + // Fast path: binary exists and is already the correct version — skip lock entirely. + if (isValidCliInstallation(cliPath, log) && isCorrectVersion(toolLocation, instance, repository, version, binaryName, log)) { + log.getLogger().println("[BinaryInstaller] CLI already installed and up-to-date, skipping download"); + return toolLocation; + } + + // Slow path: need to install or upgrade. + String lockKey = createLockKey(toolLocation, binaryName); + ReentrantLock installationLock = NODE_INSTALLATION_LOCKS.computeIfAbsent(lockKey, k -> new ReentrantLock()); + + int timeoutMinutes = getInstallTimeoutMinutes(); + log.getLogger().println("[BinaryInstaller] Acquiring installation lock for: " + lockKey + " (timeout: " + timeoutMinutes + " min)"); + + if (!installationLock.tryLock(timeoutMinutes, TimeUnit.MINUTES)) { + log.getLogger().println("[BinaryInstaller] WARNING: Could not acquire installation lock within " + timeoutMinutes + " minutes for: " + lockKey); + if (isValidCliInstallation(cliPath, log)) { + log.getLogger().println("[BinaryInstaller] Using existing binary while installation is in progress: " + cliPath.getRemote()); + return toolLocation; + } + throw new IOException("Timed out after " + timeoutMinutes + " minutes waiting for JFrog CLI installation and no binary exists at: " + cliPath.getRemote() + + ". Set " + INSTALL_TIMEOUT_ENV_VAR + " to increase the timeout."); + } + + log.getLogger().println("[BinaryInstaller] Lock acquired, proceeding with installation"); + try { + // Re-check inside the lock — a concurrent stage may have just finished. + boolean validCliExists = isValidCliInstallation(cliPath, log); + if (validCliExists && isCorrectVersion(toolLocation, instance, repository, version, binaryName, log)) { + log.getLogger().println("[BinaryInstaller] CLI was installed by a concurrent stage, skipping download"); + return toolLocation; + } + + if (validCliExists) { + log.getLogger().println("[BinaryInstaller] CLI version mismatch detected, upgrading"); + } else { + log.getLogger().println("[BinaryInstaller] No valid CLI installation found, downloading"); + } + try { - // Check if CLI already exists and is the correct version - FilePath cliPath = toolLocation.child(binaryName); - boolean validCliExists = isValidCliInstallation(cliPath, log); - if (validCliExists && isCorrectVersion(toolLocation, instance, repository, version, binaryName, log)) { - log.getLogger().println("[BinaryInstaller] CLI already installed and up-to-date, skipping download"); - return toolLocation; - } else if (validCliExists) { - log.getLogger().println("[BinaryInstaller] CLI exists but version mismatch detected, proceeding with upgrade"); - } else { - log.getLogger().println("[BinaryInstaller] No valid CLI installation found, proceeding with fresh installation"); - } - - // Clean up any stale lock entries for this location (different versions) - cleanupStaleLocks(toolLocation, binaryName, version); - - log.getLogger().println("[BinaryInstaller] Starting CLI installation process"); - - // Perform the actual download using the improved JFrogCliDownloader JenkinsProxyConfiguration proxyConfiguration = new JenkinsProxyConfiguration(); toolLocation.act(new JFrogCliDownloader(proxyConfiguration, version, instance, log, repository, binaryName)); - log.getLogger().println("[BinaryInstaller] CLI installation completed successfully"); - return toolLocation; - - } finally { - log.getLogger().println("[BinaryInstaller] Installation lock released for: " + lockKey); + } catch (Exception e) { + // Download failed. If an older binary is still present, keep the pipeline running. + // The upgrade will be retried on the next run. + if (isValidCliInstallation(cliPath, log)) { + log.getLogger().println("[BinaryInstaller] WARNING: Download failed (" + e.getMessage() + + "), falling back to existing binary at: " + cliPath.getRemote()); + return toolLocation; + } + // No binary to fall back to — this is a genuine unrecoverable failure. + throw new IOException("JFrog CLI download failed and no existing binary is available: " + e.getMessage(), e); } + + return toolLocation; + + } finally { + installationLock.unlock(); + log.getLogger().println("[BinaryInstaller] Installation lock released for: " + lockKey); } } + /** + * Returns the lock-acquisition timeout in minutes. + * Reads {@value #INSTALL_TIMEOUT_ENV_VAR} from the environment; falls back to + * {@value #DEFAULT_INSTALL_TIMEOUT_MINUTES} minutes if the variable is absent or invalid. + * Values below 1 are silently clamped to the default. + */ + static int getInstallTimeoutMinutes() { + String envValue = System.getenv(INSTALL_TIMEOUT_ENV_VAR); + if (StringUtils.isNotBlank(envValue)) { + try { + int parsed = Integer.parseInt(envValue.trim()); + if (parsed >= 1) { + return parsed; + } + LOGGER.warning(INSTALL_TIMEOUT_ENV_VAR + "=" + envValue + " is less than 1, using default " + DEFAULT_INSTALL_TIMEOUT_MINUTES + " minutes"); + } catch (NumberFormatException e) { + LOGGER.warning(INSTALL_TIMEOUT_ENV_VAR + "=" + envValue + " is not a valid integer, using default " + DEFAULT_INSTALL_TIMEOUT_MINUTES + " minutes"); + } + } + return DEFAULT_INSTALL_TIMEOUT_MINUTES; + } + /** * Creates a unique lock key for the installation location. * Version is excluded so all operations targeting the same binary path are serialized. * * @param toolLocation Installation directory * @param binaryName Binary file name - * @param version CLI version being installed (unused, kept for signature compatibility) * @return Unique lock key string */ - private static String createLockKey(FilePath toolLocation, String binaryName, String version) { + private static String createLockKey(FilePath toolLocation, String binaryName) { try { return toolLocation.getRemote() + "/" + binaryName; } catch (Exception e) { @@ -163,42 +228,32 @@ private static String createLockKey(FilePath toolLocation, String binaryName, St * @param log Task listener for logging * @return true if valid CLI exists, false otherwise */ + /** + * Checks existence, size (> 1 MB), and executable permission in a single agent RPC. + */ private static boolean isValidCliInstallation(FilePath cliPath, TaskListener log) { try { - if (cliPath.exists()) { - // Check if file is executable and has reasonable size (> 1MB) - long fileSize = cliPath.length(); - if (fileSize > 1024 * 1024 && isExecutable(cliPath)) { // > 1MB - log.getLogger().println("[BinaryInstaller] Found existing CLI: " + cliPath.getRemote() + - " (size: " + (fileSize / 1024 / 1024) + "MB)"); - return true; + long[] result = cliPath.act(new MasterToSlaveFileCallable() { + @Override + public long[] invoke(File file, VirtualChannel channel) { + if (!file.exists() || file.isDirectory()) { + return new long[]{0, 0}; + } + String name = file.getName().toLowerCase(); + boolean executable = name.endsWith(".exe") || file.canExecute(); + return new long[]{file.length(), executable ? 1 : 0}; } + }); + if (result[0] > 1024 * 1024 && result[1] == 1) { + log.getLogger().println("[BinaryInstaller] Found existing CLI: " + cliPath.getRemote() + + " (size: " + (result[0] / 1024 / 1024) + "MB)"); + return true; } } catch (Exception e) { LOGGER.warning("Failed to check existing CLI installation: " + e.getMessage()); } return false; } - - /** - * Verify the CLI file is executable on the target node. - * On Windows, treat .exe files as executable. - */ - private static boolean isExecutable(FilePath cliPath) throws IOException, InterruptedException { - return cliPath.act(new MasterToSlaveFileCallable() { - @Override - public Boolean invoke(File file, VirtualChannel channel) { - if (!file.exists() || file.isDirectory()) { - return false; - } - String name = file.getName().toLowerCase(); - if (name.endsWith(".exe")) { - return true; - } - return file.canExecute(); - } - }); - } /** * Check if the installed CLI is the correct version by comparing SHA256 hashes. @@ -239,7 +294,7 @@ private static boolean isCorrectVersion(FilePath toolLocation, JFrogPlatformInst // Get expected SHA256 from Artifactory String expectedSha256 = getArtifactSha256(manager, cliUrlSuffix); if (expectedSha256.isEmpty()) { - log.getLogger().println("[BinaryInstaller] No SHA256 available from server, reusing existing valid CLI"); + log.getLogger().println("[BinaryInstaller] WARNING: No SHA256 available from server — cannot verify version, assuming up-to-date (upgrade may be delayed)"); return true; } @@ -253,7 +308,7 @@ public Boolean invoke(File f, VirtualChannel channel) throws IOException, Interr } String localSha256 = new String(Files.readAllBytes(sha256File.toPath()), StandardCharsets.UTF_8); - return constantTimeEquals(expectedSha256, localSha256); + return StringUtils.equals(expectedSha256, localSha256); } }); } @@ -279,54 +334,6 @@ private static String getArtifactSha256(ArtifactoryManager manager, String cliUr return ""; } - /** - * Clean up stale lock entries for different versions of the same CLI at the same location. - * This prevents memory leaks in the NODE_INSTALLATION_LOCKS map during version upgrades. - * - * @param toolLocation Installation directory - * @param binaryName Binary file name - * @param currentVersion Current version being installed - */ - private static void cleanupStaleLocks(FilePath toolLocation, String binaryName, String currentVersion) { - try { - String locationPrefix = toolLocation.getRemote() + "/" + binaryName + "/"; - String currentLockKey = locationPrefix + currentVersion; - - // Remove old version lock entries for the same location - NODE_INSTALLATION_LOCKS.entrySet().removeIf(entry -> { - String key = entry.getKey(); - return key.startsWith(locationPrefix) && !key.equals(currentLockKey); - }); - - } catch (Exception e) { - // If cleanup fails, it's not critical - just log and continue - LOGGER.fine("Failed to cleanup stale locks: " + e.getMessage()); - } - } - - /** - * Constant-time comparison of two strings to prevent timing attacks. - * This is especially important for comparing cryptographic hashes like SHA256. - * - * @param a First string to compare - * @param b Second string to compare - * @return true if strings are equal, false otherwise - */ - private static boolean constantTimeEquals(String a, String b) { - if (a == null || b == null) { - return Objects.equals(a, b); - } - - if (a.length() != b.length()) { - return false; - } - - int result = 0; - for (int i = 0; i < a.length(); i++) { - result |= a.charAt(i) ^ b.charAt(i); - } - - return result == 0; - } } + diff --git a/src/main/java/io/jenkins/plugins/jfrog/JfStep.java b/src/main/java/io/jenkins/plugins/jfrog/JfStep.java index c36b72e4..ab807be3 100644 --- a/src/main/java/io/jenkins/plugins/jfrog/JfStep.java +++ b/src/main/java/io/jenkins/plugins/jfrog/JfStep.java @@ -189,11 +189,18 @@ private void logIfNoToolProvided(EnvVars env, TaskListener listener) { * @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, boolean passwordStdinSupported) 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 - jfrogCliConfigEncryption = new JFrogCliConfigEncryption(env); - run.addAction(jfrogCliConfigEncryption); + // Synchronize on the run object to ensure only one encryption key is created + // even when multiple parallel stages call this method concurrently. + // Without synchronization, each stage would create a different random key, + // and the stages would encrypt/decrypt the shared config file with mismatched + // keys → "cipher: message authentication failed". + JFrogCliConfigEncryption jfrogCliConfigEncryption; + synchronized (run) { + jfrogCliConfigEncryption = run.getAction(JFrogCliConfigEncryption.class); + if (jfrogCliConfigEncryption == null) { + jfrogCliConfigEncryption = new JFrogCliConfigEncryption(env); + run.addAction(jfrogCliConfigEncryption); + } } FilePath jfrogHomeTempDir = Utils.createAndGetJfrogCliHomeTempDir(workspace, String.valueOf(run.getNumber())); CliEnvConfigurator.configureCliEnv(env, jfrogHomeTempDir, jfrogCliConfigEncryption); diff --git a/src/main/java/io/jenkins/plugins/jfrog/JfrogBuildInfoPublisher.java b/src/main/java/io/jenkins/plugins/jfrog/JfrogBuildInfoPublisher.java index 5bd34ba9..22dc053e 100644 --- a/src/main/java/io/jenkins/plugins/jfrog/JfrogBuildInfoPublisher.java +++ b/src/main/java/io/jenkins/plugins/jfrog/JfrogBuildInfoPublisher.java @@ -116,7 +116,7 @@ public boolean perform(AbstractBuild build, Launcher launcher, BuildListen } FilePath jfrogHomeTempDir = Utils.createAndGetJfrogCliHomeTempDir(workspace, String.valueOf(build.getNumber())); - CliEnvConfigurator.configureCliEnv(env, jfrogHomeTempDir.getRemote(), jfrogCliConfigEncryption); + CliEnvConfigurator.configureCliEnv(env, jfrogHomeTempDir, jfrogCliConfigEncryption); // Build the 'jf rt build-publish' command ArgumentListBuilder builder = new ArgumentListBuilder(); @@ -169,13 +169,7 @@ private boolean shouldConfig(FilePath jfrogHomeTempDir) throws IOException, Inte if (jfrogHomeTempDir == null || !jfrogHomeTempDir.exists()) { return true; } - - for (FilePath file : jfrogHomeTempDir.list()) { - if (file != null && file.getName().contains("jfrog-cli.conf")) { - return false; - } - } - return true; + return !jfrogHomeTempDir.child("jfrog-cli.conf").exists(); } /** diff --git a/src/main/java/io/jenkins/plugins/jfrog/JfrogCliWrapper.java b/src/main/java/io/jenkins/plugins/jfrog/JfrogCliWrapper.java index 2d065676..20782e30 100644 --- a/src/main/java/io/jenkins/plugins/jfrog/JfrogCliWrapper.java +++ b/src/main/java/io/jenkins/plugins/jfrog/JfrogCliWrapper.java @@ -109,26 +109,17 @@ private JfrogInstallation getInstallation() { } /** - * Get the node from workspace. + * Get the node from workspace using the Computer API. */ private hudson.model.Node workspaceToNode(FilePath workspace) { - jenkins.model.Jenkins jenkinsInstance = jenkins.model.Jenkins.getInstanceOrNull(); - if (jenkinsInstance == null || workspace == null) { + if (workspace == null) { return null; } - - // Check if workspace is on master - if (workspace.getChannel() == jenkinsInstance.getChannel()) { - return jenkinsInstance; - } - - // Find the node that owns this workspace - for (hudson.model.Node node : jenkinsInstance.getNodes()) { - if (node.getChannel() == workspace.getChannel()) { - return node; - } + hudson.model.Computer computer = workspace.toComputer(); + if (computer == null) { + return null; } - return null; + return computer.getNode(); } @Extension @@ -154,11 +145,15 @@ public String getDisplayName() { */ @Override public boolean isApplicable(AbstractProject item) { - // Exclude Matrix projects - they run across multiple nodes - // Use class name check to avoid hard dependency on matrix-project plugin - String className = item.getClass().getName(); - if (className.contains("MatrixProject") || className.contains("MatrixConfiguration")) { - return false; + // Exclude Matrix projects - they run across multiple nodes where CLI installations may differ. + // Use reflection to avoid a hard compile-time dependency on the matrix-project plugin. + try { + Class matrixProjectClass = Class.forName("hudson.matrix.MatrixProject"); + if (matrixProjectClass.isInstance(item)) { + return false; + } + } catch (ClassNotFoundException e) { + // matrix-project plugin not installed; nothing to exclude } return true; } diff --git a/src/main/java/io/jenkins/plugins/jfrog/callables/JFrogCliDownloader.java b/src/main/java/io/jenkins/plugins/jfrog/callables/JFrogCliDownloader.java index 45ee7b18..e40272e5 100644 --- a/src/main/java/io/jenkins/plugins/jfrog/callables/JFrogCliDownloader.java +++ b/src/main/java/io/jenkins/plugins/jfrog/callables/JFrogCliDownloader.java @@ -198,87 +198,79 @@ private void performDownloadWithLock(File toolLocation) throws IOException, Inte } /** - * Performs atomic download operations for reliable file installation. - * Used for fresh installations where we MUST succeed. - * - * APPROACH: - * 1. Generate unique temporary file name to avoid conflicts - * 2. Download to temporary file - * 3. Verify download integrity - * 4. Atomic move from temp to final location (with retry for Windows) - * 5. Set executable permissions - * 6. Create SHA256 verification file - * 7. Cleanup temporary file on any failure - * + * Downloads the CLI binary to a unique temporary file and verifies its integrity. + * * @param manager ArtifactoryManager for download operations * @param cliUrlSuffix URL suffix for the CLI binary * @param toolLocation Target directory for installation - * @param artifactorySha256 Expected SHA256 hash for verification - * @throws IOException If download or file operations fail + * @return The downloaded temporary file (caller is responsible for cleanup) + * @throws IOException If download or verification fails */ - private void performAtomicDownload(ArtifactoryManager manager, String cliUrlSuffix, - File toolLocation, String artifactorySha256) throws IOException { - + private File downloadToTemp(ArtifactoryManager manager, String cliUrlSuffix, File toolLocation) throws IOException { String stageName = getStageNameFromThread(); - String tempFileName = binaryName + ".tmp." + - stageName + "." + - System.currentTimeMillis() + "." + - Thread.currentThread().getId() + "." + - System.nanoTime(); - + String tempFileName = binaryName + ".tmp." + + stageName + "." + + System.currentTimeMillis() + "." + + Thread.currentThread().getId() + "." + + System.nanoTime(); + File temporaryDownloadFile = new File(toolLocation, tempFileName); + log.getLogger().println("[JFrogCliDownloader] Downloading to temporary file: " + temporaryDownloadFile.getAbsolutePath()); + manager.downloadToFile(cliUrlSuffix, temporaryDownloadFile.getPath()); + + if (!temporaryDownloadFile.exists()) { + throw new IOException("Downloaded file doesn't exist: " + temporaryDownloadFile.getAbsolutePath()); + } + long fileSize = temporaryDownloadFile.length(); + if (fileSize == 0) { + throw new IOException("Downloaded file is empty: " + temporaryDownloadFile.getAbsolutePath()); + } + log.getLogger().println("[JFrogCliDownloader] Download verified: " + (fileSize / 1024 / 1024) + "MB"); + return temporaryDownloadFile; + } + + /** + * Sets executable permissions and creates the SHA256 verification file after a successful move. + */ + private void finalizeInstall(File finalCliExecutable, File toolLocation, String artifactorySha256) throws IOException { + log.getLogger().println("[JFrogCliDownloader] Setting executable permissions"); + if (!finalCliExecutable.setExecutable(true)) { + throw new IOException("No permission to add execution permission to binary: " + finalCliExecutable.getAbsolutePath()); + } + log.getLogger().println("[JFrogCliDownloader] Creating SHA256 verification file"); + createSha256File(toolLocation, artifactorySha256); + } + + /** + * Performs atomic download for fresh installations. Always throws on failure. + * + * @param manager ArtifactoryManager for download operations + * @param cliUrlSuffix URL suffix for the CLI binary + * @param toolLocation Target directory for installation + * @param artifactorySha256 Expected SHA256 hash for verification + * @throws IOException If download or file operations fail + */ + private void performAtomicDownload(ArtifactoryManager manager, String cliUrlSuffix, + File toolLocation, String artifactorySha256) throws IOException { File finalCliExecutable = new File(toolLocation, binaryName); - - log.getLogger().println("[JFrogCliDownloader] Temporary download file: " + temporaryDownloadFile.getAbsolutePath()); - log.getLogger().println("[JFrogCliDownloader] Final CLI executable: " + finalCliExecutable.getAbsolutePath()); - + File temporaryDownloadFile = null; try { - // Download to temporary file - log.getLogger().println("[JFrogCliDownloader] Downloading to temporary file"); - manager.downloadToFile(cliUrlSuffix, temporaryDownloadFile.getPath()); - - // Verify download integrity - log.getLogger().println("[JFrogCliDownloader] Verifying download integrity"); - if (!temporaryDownloadFile.exists()) { - throw new IOException("Downloaded file doesn't exist: " + temporaryDownloadFile.getAbsolutePath()); - } - - long fileSize = temporaryDownloadFile.length(); - if (fileSize == 0) { - throw new IOException("Downloaded file is empty: " + temporaryDownloadFile.getAbsolutePath()); - } - - log.getLogger().println("[JFrogCliDownloader] Download verified: " + (fileSize / 1024 / 1024) + "MB"); - - // Move to final location using NIO with retry for Windows file locking issues - log.getLogger().println("[JFrogCliDownloader] Moving to final location"); + temporaryDownloadFile = downloadToTemp(manager, cliUrlSuffix, toolLocation); + log.getLogger().println("[JFrogCliDownloader] Moving to final location: " + finalCliExecutable.getAbsolutePath()); moveFileWithRetry(temporaryDownloadFile, finalCliExecutable); - - // Set executable permissions on final CLI binary - log.getLogger().println("[JFrogCliDownloader] Setting executable permissions"); - if (!finalCliExecutable.setExecutable(true)) { - throw new IOException("No permission to add execution permission to binary: " + finalCliExecutable.getAbsolutePath()); - } - - // Create SHA256 verification file - log.getLogger().println("[JFrogCliDownloader] Creating SHA256 verification file"); - createSha256File(toolLocation, artifactorySha256); - + finalizeInstall(finalCliExecutable, toolLocation, artifactorySha256); log.getLogger().println("[JFrogCliDownloader] Download and installation completed successfully"); - } catch (Exception e) { - // Cleanup temporary file on failure log.getLogger().println("[JFrogCliDownloader] Download failed, cleaning up temporary file"); cleanupTempFile(temporaryDownloadFile); throw e; } } - + /** * Performs atomic download for upgrade scenario with graceful fallback. - * If the target binary is locked (in use), this method returns false to indicate - * the upgrade was skipped, allowing the caller to use the existing CLI version. - * + * Returns false if the target binary is locked on Windows, allowing the caller to use the existing CLI. + * * @param manager ArtifactoryManager for download operations * @param cliUrlSuffix URL suffix for the CLI binary * @param toolLocation Target directory for installation @@ -287,75 +279,30 @@ private void performAtomicDownload(ArtifactoryManager manager, String cliUrlSuff * @return true if upgrade succeeded, false if skipped due to file locking * @throws IOException If download fails for non-recoverable reasons (not file locking) */ - private boolean performAtomicDownloadForUpgrade(ArtifactoryManager manager, String cliUrlSuffix, - File toolLocation, String artifactorySha256, - File existingCli) throws IOException { - - String stageName = getStageNameFromThread(); - String tempFileName = binaryName + ".tmp." + - stageName + "." + - System.currentTimeMillis() + "." + - Thread.currentThread().getId() + "." + - System.nanoTime(); - - File temporaryDownloadFile = new File(toolLocation, tempFileName); + private boolean performAtomicDownloadForUpgrade(ArtifactoryManager manager, String cliUrlSuffix, + File toolLocation, String artifactorySha256, + File existingCli) throws IOException { File finalCliExecutable = new File(toolLocation, binaryName); - - log.getLogger().println("[JFrogCliDownloader] Temporary download file: " + temporaryDownloadFile.getAbsolutePath()); - log.getLogger().println("[JFrogCliDownloader] Final CLI executable: " + finalCliExecutable.getAbsolutePath()); - + File temporaryDownloadFile = null; try { - // Download to temporary file - log.getLogger().println("[JFrogCliDownloader] Downloading to temporary file"); - manager.downloadToFile(cliUrlSuffix, temporaryDownloadFile.getPath()); - - // Verify download integrity - log.getLogger().println("[JFrogCliDownloader] Verifying download integrity"); - if (!temporaryDownloadFile.exists()) { - throw new IOException("Downloaded file doesn't exist: " + temporaryDownloadFile.getAbsolutePath()); - } - - long fileSize = temporaryDownloadFile.length(); - if (fileSize == 0) { - throw new IOException("Downloaded file is empty: " + temporaryDownloadFile.getAbsolutePath()); - } - - log.getLogger().println("[JFrogCliDownloader] Download verified: " + (fileSize / 1024 / 1024) + "MB"); - - // Try to move to final location - for upgrades, gracefully handle file locking - log.getLogger().println("[JFrogCliDownloader] Attempting to replace existing CLI"); + temporaryDownloadFile = downloadToTemp(manager, cliUrlSuffix, toolLocation); + log.getLogger().println("[JFrogCliDownloader] Attempting to replace existing CLI: " + finalCliExecutable.getAbsolutePath()); boolean moveSucceeded = tryMoveFileForUpgrade(temporaryDownloadFile, finalCliExecutable); - if (!moveSucceeded) { - // File is locked - skip upgrade, use existing log.getLogger().println("[JFrogCliDownloader] WARNING: Existing CLI is in use by another process. " + - "Upgrade skipped. Using existing version. Upgrade will be attempted in next build."); + "Upgrade skipped. Using existing version. Upgrade will be attempted in next build."); cleanupTempFile(temporaryDownloadFile); return false; } - - // Set executable permissions on final CLI binary - log.getLogger().println("[JFrogCliDownloader] Setting executable permissions"); - if (!finalCliExecutable.setExecutable(true)) { - throw new IOException("No permission to add execution permission to binary: " + finalCliExecutable.getAbsolutePath()); - } - - // Create SHA256 verification file - log.getLogger().println("[JFrogCliDownloader] Creating SHA256 verification file"); - createSha256File(toolLocation, artifactorySha256); - + finalizeInstall(finalCliExecutable, toolLocation, artifactorySha256); return true; - } catch (IOException e) { - // For upgrade failures, check if we can fall back to existing if (isWindowsTarget() && isFileLockingError(e) && isExistingCliValid(existingCli)) { log.getLogger().println("[JFrogCliDownloader] WARNING: Upgrade failed due to file locking. " + - "Using existing CLI version. Upgrade will be attempted in next build."); + "Using existing CLI version. Upgrade will be attempted in next build."); cleanupTempFile(temporaryDownloadFile); return false; } - - // Non-recoverable error cleanupTempFile(temporaryDownloadFile); throw e; } @@ -420,8 +367,7 @@ private boolean containsLockingMessage(String message) { return message.contains("being used by another process") || message.contains("Access is denied") || message.contains("cannot access the file") || - message.contains("locked") || - message.contains("in use"); + message.contains("The process cannot access the file"); } /** @@ -437,39 +383,30 @@ private boolean containsLockingMessage(String message) { private void moveFileWithRetry(File source, File target) throws IOException { int maxRetries = 5; long retryDelayMs = 1000; - + for (int attempt = 1; attempt <= maxRetries; attempt++) { try { - // Use Files.move with REPLACE_EXISTING for atomic replacement - // Note: ATOMIC_MOVE is not always supported on Windows, so we use REPLACE_EXISTING + // Use Files.move with REPLACE_EXISTING for atomic replacement. + // Note: ATOMIC_MOVE is not always supported on Windows, so we use REPLACE_EXISTING. Files.move(source.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING); log.getLogger().println("[JFrogCliDownloader] File moved successfully to: " + target.getAbsolutePath()); return; } catch (IOException e) { boolean isLastAttempt = (attempt == maxRetries); - String errorMsg = e.getMessage(); - - // Check if this is a Windows file locking issue - boolean isFileLockingIssue = errorMsg != null && - (errorMsg.contains("being used by another process") || - errorMsg.contains("Access is denied") || - errorMsg.contains("cannot access the file")); - - if (isFileLockingIssue && !isLastAttempt) { - log.getLogger().println("[JFrogCliDownloader] File locked, retrying in " + - retryDelayMs + "ms (attempt " + attempt + "/" + maxRetries + ")"); + if (isFileLockingError(e) && !isLastAttempt) { + log.getLogger().println("[JFrogCliDownloader] File locked, retrying in " + + retryDelayMs + "ms (attempt " + attempt + "/" + maxRetries + ")"); try { Thread.sleep(retryDelayMs); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); throw new IOException("Interrupted while waiting to retry file move", ie); } - // Exponential backoff retryDelayMs *= 2; } else { - throw new IOException("Failed to move file from " + source.getAbsolutePath() + - " to " + target.getAbsolutePath() + - " after " + attempt + " attempts: " + errorMsg, e); + throw new IOException("Failed to move file from " + source.getAbsolutePath() + + " to " + target.getAbsolutePath() + + " after " + attempt + " attempts: " + e.getMessage(), e); } } } @@ -494,12 +431,21 @@ private void cleanupTempFile(File tempFile) { } } + /** + * Atomically writes the sha256 verification file using a temp-then-rename approach. + * A plain {@code Files.write()} truncates the target file before writing content, + * creating a brief window where readers see a 0-byte file and treat the installation + * as stale (triggering a re-download loop). Writing to a temp file and renaming + * eliminates that window because the rename is atomic on all supported file systems. + */ private static void createSha256File(File toolLocation, String artifactorySha256) throws IOException { if (StringUtils.isBlank(artifactorySha256)) { return; } - File file = new File(toolLocation, SHA256_FILE_NAME); - Files.write(file.toPath(), artifactorySha256.getBytes(StandardCharsets.UTF_8)); + File targetFile = new File(toolLocation, SHA256_FILE_NAME); + File tempFile = new File(toolLocation, SHA256_FILE_NAME + ".tmp"); + Files.write(tempFile.toPath(), artifactorySha256.getBytes(StandardCharsets.UTF_8)); + Files.move(tempFile.toPath(), targetFile.toPath(), StandardCopyOption.REPLACE_EXISTING); } /** @@ -507,6 +453,11 @@ private static void createSha256File(File toolLocation, String artifactorySha256 * A file named 'sha256' contains the specific binary sha256. * If the file sha256 has not changed, we will skip the download, otherwise we will download and overwrite the existing files. * + *

A 0-byte sha256 file (left by a previous failed write or an older plugin version) + * is treated the same as a missing file: the tool is re-downloaded and the file is + * replaced with the correct hash. The stale 0-byte file is deleted immediately so it + * cannot confuse any concurrent readers.

+ * * @param toolLocation - expected location of the tool on the fileSystem. * @param artifactorySha256 - sha256 of the expected file in artifactory. */ @@ -521,6 +472,12 @@ private static boolean shouldDownloadTool(File toolLocation, String artifactoryS return true; } String fileContent = new String(Files.readAllBytes(path), StandardCharsets.UTF_8); + if (StringUtils.isBlank(fileContent)) { + // 0-byte or whitespace-only sha256 file — corrupted/legacy state. + // Delete it so a fresh, correct file is written after the download. + Files.deleteIfExists(path); + return true; + } return !StringUtils.equals(fileContent, artifactorySha256); } diff --git a/src/test/java/io/jenkins/plugins/jfrog/integration/FreestyleJobITest.java b/src/test/java/io/jenkins/plugins/jfrog/integration/FreestyleJobITest.java new file mode 100644 index 00000000..ffba9793 --- /dev/null +++ b/src/test/java/io/jenkins/plugins/jfrog/integration/FreestyleJobITest.java @@ -0,0 +1,133 @@ +package io.jenkins.plugins.jfrog.integration; + +import hudson.model.FreeStyleBuild; +import hudson.model.FreeStyleProject; +import hudson.model.Result; +import hudson.tasks.Shell; +import io.jenkins.plugins.jfrog.JfrogBuildInfoPublisher; +import io.jenkins.plugins.jfrog.JfrogCliWrapper; +import org.apache.commons.lang3.StringUtils; +import org.junit.jupiter.api.Test; +import org.jvnet.hudson.test.JenkinsRule; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Integration tests for the Freestyle job extensions: + * - JfrogCliWrapper (Build Environment wrapper) + * - JfrogBuildInfoPublisher (Post-build action) + */ +class FreestyleJobITest extends PipelineTestBase { + + /** + * JfrogCliWrapper should add JFROG_BINARY_PATH to the build environment. + * + * @param jenkins Jenkins instance injected automatically. + */ + @Test + public void testWrapperSetsBinaryPath(JenkinsRule jenkins) throws Exception { + setupJenkins(jenkins); + configureJfrogCliFromReleases(StringUtils.EMPTY, true); + + FreeStyleProject project = jenkins.createFreeStyleProject("test-wrapper-path"); + JfrogCliWrapper wrapper = new JfrogCliWrapper(); + wrapper.setJfrogInstallation(JFROG_CLI_TOOL_NAME_1); + project.getBuildWrappersList().add(wrapper); + + // Add a shell step that checks JFROG_BINARY_PATH is set + project.getBuildersList().add( + new Shell("echo \"Binary: $JFROG_BINARY_PATH\" && test -n \"$JFROG_BINARY_PATH\"") + ); + + FreeStyleBuild build = jenkins.buildAndAssertSuccess(project); + assertNotNull(build); + } + + /** + * Without any installation selected, JfrogCliWrapper should still allow the build + * to proceed (uses system PATH). + * + * @param jenkins Jenkins instance injected automatically. + */ + @Test + public void testWrapperNoInstallationUsesSysPath(JenkinsRule jenkins) throws Exception { + setupJenkins(jenkins); + + FreeStyleProject project = jenkins.createFreeStyleProject("test-wrapper-no-install"); + JfrogCliWrapper wrapper = new JfrogCliWrapper(); + // No installation set — uses system PATH + project.getBuildWrappersList().add(wrapper); + project.getBuildersList().add(new Shell("echo 'no installation, continuing'")); + + FreeStyleBuild build = jenkins.buildAndAssertSuccess(project); + assertNotNull(build); + } + + /** + * JfrogBuildInfoPublisher with publishOnlyOnSuccess=true must skip publish + * when the build has failed. + * + * @param jenkins Jenkins instance injected automatically. + */ + @Test + public void testPublisherSkipsOnFailedBuild(JenkinsRule jenkins) throws Exception { + setupJenkins(jenkins); + + FreeStyleProject project = jenkins.createFreeStyleProject("test-publisher-skip"); + // Deliberately fail the build + project.getBuildersList().add(new Shell("exit 1")); + + JfrogBuildInfoPublisher publisher = new JfrogBuildInfoPublisher(); + publisher.setPublishOnlyOnSuccess(true); + project.getPublishersList().add(publisher); + + // Build should fail at the shell step; publisher should skip cleanly (no NPE or error from publisher) + FreeStyleBuild build = jenkins.buildAndAssertStatus(Result.FAILURE, project); + assertNotNull(build); + } + + /** + * Freestyle flow: wrapper sets environment and build step successfully invokes the JFrog CLI. + * Verifies that JFROG_BINARY_PATH points to the installation directory and 'jf -v' succeeds. + * + * @param jenkins Jenkins instance injected automatically. + */ + @Test + public void testFullFreestyleFlow(JenkinsRule jenkins) throws Exception { + setupJenkins(jenkins); + configureJfrogCliFromReleases(StringUtils.EMPTY, true); + + FreeStyleProject project = jenkins.createFreeStyleProject("test-freestyle-full"); + + // Build environment: set up JFrog CLI + JfrogCliWrapper wrapper = new JfrogCliWrapper(); + wrapper.setJfrogInstallation(JFROG_CLI_TOOL_NAME_1); + project.getBuildWrappersList().add(wrapper); + + // Build step: version check (JFROG_BINARY_PATH is the directory; append the binary name) + project.getBuildersList().add(new Shell("\"$JFROG_BINARY_PATH/jf\" -v")); + + FreeStyleBuild build = jenkins.buildAndAssertSuccess(project); + assertNotNull(build); + } + + /** + * JfrogCliWrapper.DescriptorImpl.isApplicable must return true for a regular + * FreeStyle project and false for a Matrix project (validated via reflection guard). + * + * @param jenkins Jenkins instance injected automatically. + */ + @Test + public void testWrapperNotApplicableForMatrixProject(JenkinsRule jenkins) throws Exception { + setupJenkins(jenkins); + + JfrogCliWrapper.DescriptorImpl descriptor = + jenkins.jenkins.getDescriptorByType(JfrogCliWrapper.DescriptorImpl.class); + assertNotNull(descriptor); + + // For a regular FreeStyle project, it must be applicable + FreeStyleProject freeStyle = jenkins.createFreeStyleProject("test-is-applicable"); + assertTrue(descriptor.isApplicable(freeStyle)); + } +} diff --git a/src/test/java/io/jenkins/plugins/jfrog/integration/ParallelInstallITest.java b/src/test/java/io/jenkins/plugins/jfrog/integration/ParallelInstallITest.java new file mode 100644 index 00000000..3e5dbbe7 --- /dev/null +++ b/src/test/java/io/jenkins/plugins/jfrog/integration/ParallelInstallITest.java @@ -0,0 +1,72 @@ +package io.jenkins.plugins.jfrog.integration; + +import io.jenkins.plugins.jfrog.ReleasesInstaller; +import org.apache.commons.lang3.StringUtils; +import org.junit.jupiter.api.Test; +import org.jvnet.hudson.test.JenkinsRule; + +/** + * Integration tests verifying that parallel JFrog CLI installations do not corrupt + * the binary or produce race conditions. + */ +class ParallelInstallITest extends PipelineTestBase { + + /** + * Two parallel pipeline stages that share the same JFrog CLI tool must both + * complete successfully without binary corruption. + * + * @param jenkins Jenkins instance injected automatically. + */ + @Test + public void testParallelStagesSameToolVersion(JenkinsRule jenkins) throws Exception { + setupJenkins(jenkins); + configureJfrogCliFromReleases(StringUtils.EMPTY, true); + runPipeline(jenkins, "parallel_install"); + } + + /** + * Two parallel pipeline stages each using a different CLI version must both + * complete and produce independent, valid binaries. + * + * @param jenkins Jenkins instance injected automatically. + */ + @Test + public void testParallelStagesTwoToolVersions(JenkinsRule jenkins) throws Exception { + setupJenkins(jenkins); + // Tool 1: latest CLI version + configureJfrogCliFromReleases(StringUtils.EMPTY, true); + // Tool 2: a known older specific version to verify independent parallel installs + ReleasesInstaller installer = new ReleasesInstaller(); + installer.setVersion("2.29.2"); + configureJfrogCliTool(JFROG_CLI_TOOL_NAME_2, installer, false); + runPipeline(jenkins, "parallel_install_two_tools"); + } + + /** + * Repeated installs of the same version should detect the valid cached binary + * and skip re-download (verified by both runs succeeding without error). + * + * @param jenkins Jenkins instance injected automatically. + */ + @Test + public void testRepeatedInstallSkipsDownload(JenkinsRule jenkins) throws Exception { + setupJenkins(jenkins); + configureJfrogCliFromReleases(StringUtils.EMPTY, true); + // First run installs + runPipeline(jenkins, "basic_version_command"); + // Second run should hit the cache + runPipeline(jenkins, "basic_version_command"); + } + + /** + * Install from Artifactory in parallel: both stages must succeed. + * + * @param jenkins Jenkins instance injected automatically. + */ + @Test + public void testParallelInstallFromArtifactory(JenkinsRule jenkins) throws Exception { + setupJenkins(jenkins); + configureJfrogCliFromArtifactory(JFROG_CLI_TOOL_NAME_1, TEST_CONFIGURED_SERVER_ID, getRepoKey(TestRepository.CLI_REMOTE_REPO), true); + runPipeline(jenkins, "parallel_install"); + } +} 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 d6cb11b4..220056dc 100644 --- a/src/test/java/io/jenkins/plugins/jfrog/integration/PipelineTestBase.java +++ b/src/test/java/io/jenkins/plugins/jfrog/integration/PipelineTestBase.java @@ -54,8 +54,8 @@ public class PipelineTestBase { private static long currentTime; private static Artifactory artifactoryClient; - private JenkinsRule jenkins; - private Slave slave; + protected JenkinsRule jenkins; + protected Slave slave; private static StringSubstitutor pipelineSubstitution; private static final String SLAVE_LABEL = "TestSlave"; static final String PLATFORM_URL = System.getenv("JFROG_URL"); @@ -63,7 +63,7 @@ public class PipelineTestBase { private static final String ARTIFACTORY_USERNAME = System.getenv("JFROG_USERNAME"); private static final String ARTIFACTORY_PASSWORD = System.getenv("JFROG_PASSWORD"); private static final String ACCESS_TOKEN = System.getenv("JFROG_ADMIN_TOKEN"); - private static final Path INTEGRATION_BASE_PATH = Paths.get(".").toAbsolutePath().normalize() + protected static final Path INTEGRATION_BASE_PATH = Paths.get(".").toAbsolutePath().normalize() .resolve(Paths.get("src", "test", "resources", "integration")); static final String JFROG_CLI_TOOL_NAME_1 = "jfrog-cli"; static final String JFROG_CLI_TOOL_NAME_2 = "jfrog-cli-2"; diff --git a/src/test/resources/integration/pipelines/parallel_install.pipeline b/src/test/resources/integration/pipelines/parallel_install.pipeline new file mode 100644 index 00000000..ae31b73b --- /dev/null +++ b/src/test/resources/integration/pipelines/parallel_install.pipeline @@ -0,0 +1,23 @@ +// Tests that parallel stages using the same JFrog CLI tool name do not corrupt installation +pipeline { + agent any + tools { + jfrog '${JFROG_CLI_TOOL_NAME_1}' + } + stages { + stage('Parallel Safety') { + parallel { + stage('Task A') { + steps { + jf '-v' + } + } + stage('Task B') { + steps { + jf '-v' + } + } + } + } + } +} diff --git a/src/test/resources/integration/pipelines/parallel_install_two_tools.pipeline b/src/test/resources/integration/pipelines/parallel_install_two_tools.pipeline new file mode 100644 index 00000000..01f75da8 --- /dev/null +++ b/src/test/resources/integration/pipelines/parallel_install_two_tools.pipeline @@ -0,0 +1,18 @@ +// Tests parallel stages where each stage uses a different JFrog CLI version +pipeline { + agent any + stages { + stage('Parallel Two Tools') { + parallel { + stage('Tool 1') { + tools { jfrog '${JFROG_CLI_TOOL_NAME_1}' } + steps { jf '-v' } + } + stage('Tool 2') { + tools { jfrog '${JFROG_CLI_TOOL_NAME_2}' } + steps { jf '-v' } + } + } + } + } +} From 688725f28256879aaf73ec0d8ecaad6dbab74e01 Mon Sep 17 00:00:00 2001 From: Bhanu Reddy Date: Sun, 15 Mar 2026 13:31:40 +0530 Subject: [PATCH 16/18] Updated git ignore --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 3e72fc82..8c6d2e94 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,7 @@ work # Mac OS .DS_Store + +# AI +.claude +.cursor From 6d1b357f1057e33174e52c6a199c6b97ad4f6f3d Mon Sep 17 00:00:00 2001 From: Bhanu Reddy Date: Sun, 15 Mar 2026 13:53:05 +0530 Subject: [PATCH 17/18] Added not to run manual tests for live artifactory instance --- pom.xml | 5 +++++ .../io/jenkins/plugins/jfrog/CliEnvConfiguratorTest.java | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index a346a404..36f67c12 100644 --- a/pom.xml +++ b/pom.xml @@ -305,6 +305,11 @@ **/*ITest.java + + + **/FreestyleJobITest.java + **/ParallelInstallITest.java + diff --git a/src/test/java/io/jenkins/plugins/jfrog/CliEnvConfiguratorTest.java b/src/test/java/io/jenkins/plugins/jfrog/CliEnvConfiguratorTest.java index 0bc198e8..44dc332b 100644 --- a/src/test/java/io/jenkins/plugins/jfrog/CliEnvConfiguratorTest.java +++ b/src/test/java/io/jenkins/plugins/jfrog/CliEnvConfiguratorTest.java @@ -54,8 +54,8 @@ public void configEncryptionTest() throws IOException, InterruptedException { assertTrue(configEncryption.shouldEncrypt()); assertEquals(32, configEncryption.getKey().length()); - invokeConfigureCliEnv("a/b/c", configEncryption); - assertEnv(envVars, JFROG_CLI_ENCRYPTION_KEY, configEncryption.getKeyOrFilePath()); + invokeConfigureCliEnv(new FilePath(tempFolder.newFolder("encryption-test")), configEncryption); + assertEnv(envVars, JFROG_CLI_ENCRYPTION_KEY, configEncryption.getKeyFilePath()); } @Test From 50a2602b3f31f63f6be8548f43f3b33c54b10d6b Mon Sep 17 00:00:00 2001 From: Bhanu Reddy Date: Sun, 15 Mar 2026 15:06:31 +0530 Subject: [PATCH 18/18] Updated lombok version --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 36f67c12..6ac24aa7 100644 --- a/pom.xml +++ b/pom.xml @@ -70,7 +70,7 @@ org.projectlombok lombok - 1.18.26 + 1.18.36 provided