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 diff --git a/README.md b/README.md index f26750bb..cf698703 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. @@ -224,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) + +To make JFrog CLI available to **all** build steps in your Freestyle job (including shell scripts), use the Build Environment wrapper: + +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 -Before using the Freestyle job support, make sure you have completed the plugin setup: +**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 -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)) +> **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 @@ -237,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 @@ -281,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/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/pom.xml b/pom.xml index a346a404..6ac24aa7 100644 --- a/pom.xml +++ b/pom.xml @@ -70,7 +70,7 @@ org.projectlombok lombok - 1.18.26 + 1.18.36 provided @@ -305,6 +305,11 @@ **/*ITest.java + + + **/FreestyleJobITest.java + **/ParallelInstallITest.java + diff --git a/src/main/java/io/jenkins/plugins/jfrog/BinaryInstaller.java b/src/main/java/io/jenkins/plugins/jfrog/BinaryInstaller.java index 2f683713..e5e7bd93 100644 --- a/src/main/java/io/jenkins/plugins/jfrog/BinaryInstaller.java +++ b/src/main/java/io/jenkins/plugins/jfrog/BinaryInstaller.java @@ -20,10 +20,13 @@ 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; + /** * Installer for JFrog CLI binary. * @@ -32,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); @@ -70,86 +92,132 @@ 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 + version combination - 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); - if (isValidCliInstallation(cliPath, log) && isCorrectVersion(toolLocation, instance, repository, version, log)) { - log.getLogger().println("[BinaryInstaller] CLI already installed and up-to-date, skipping download"); - return toolLocation; - } else if (isValidCliInstallation(cliPath, log)) { - 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); } } /** - * 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. - * + * 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 * @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 + "/" + version; + return toolLocation.getRemote() + "/" + binaryName; } catch (Exception e) { // Fallback to a simpler key if remote path access fails - return toolLocation.toString() + "/" + binaryName + "/" + version; + return "unknown-tool-location/" + binaryName; } } @@ -160,16 +228,26 @@ 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) { // > 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()); @@ -185,19 +263,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(); @@ -214,8 +294,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, assuming version check needed"); - return false; // If no SHA256, let download process handle it + log.getLogger().println("[BinaryInstaller] WARNING: No SHA256 available from server — cannot verify version, assuming up-to-date (upgrade may be delayed)"); + return true; } // Check local SHA256 file @@ -228,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); } }); } @@ -238,68 +318,22 @@ public Boolean invoke(File f, VirtualChannel channel) throws IOException, Interr return false; // If version check fails, let download process handle it } } - + /** * Get SHA256 hash from Artifactory headers (same logic as in JFrogCliDownloader) */ 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(); } } 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 new file mode 100644 index 00000000..22dc053e --- /dev/null +++ b/src/main/java/io/jenkins/plugins/jfrog/JfrogBuildInfoPublisher.java @@ -0,0 +1,231 @@ +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, 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; + } + return !jfrogHomeTempDir.child("jfrog-cli.conf").exists(); + } + + /** + * 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 bc914f95..d407cc7f 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..20782e30 --- /dev/null +++ b/src/main/java/io/jenkins/plugins/jfrog/JfrogCliWrapper.java @@ -0,0 +1,181 @@ +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 using the Computer API. + */ + private hudson.model.Node workspaceToNode(FilePath workspace) { + if (workspace == null) { + return null; + } + hudson.model.Computer computer = workspace.toComputer(); + if (computer == null) { + return null; + } + return computer.getNode(); + } + + @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 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; + } + + /** + * 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 f63355fd..e40272e5 100644 --- a/src/main/java/io/jenkins/plugins/jfrog/callables/JFrogCliDownloader.java +++ b/src/main/java/io/jenkins/plugins/jfrog/callables/JFrogCliDownloader.java @@ -16,8 +16,11 @@ import java.io.File; import java.io.IOException; 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; import static org.jfrog.build.client.DownloadResponse.SHA256_HEADER_NAME; @@ -38,6 +41,12 @@ public class JFrogCliDownloader extends MasterToSlaveFileCallable { * decoded "[RELEASE]" for the download url */ private static final String RELEASE = "[RELEASE]"; + + /** + * 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 +59,111 @@ 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()) { + if (!toolLocation.mkdirs()) { + throw new IOException("Failed to create tool location directory: " + toolLocation.getAbsolutePath()); + } + } + + // 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"); + performDownloadWithLock(toolLocation); + } else { + log.getLogger().println("[JFrogCliDownloader] Existing CLI found - attempting upgrade"); + performDownloadWithLockForUpgrade(toolLocation, existingCli); + } + + return null; + } + + /** + * 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; + } + + /** + * Performs the actual download operation for fresh installations. + * + * @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 +177,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,94 +195,220 @@ 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. - * - * 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 - * 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 + * @return The downloaded temporary file (caller is responsible for cleanup) + * @throws IOException If download or verification fails + */ + 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(); + + 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 { - - // Phase 1: Generate unique temporary file name to avoid conflicts during parallel downloads - String stageName = getStageNameFromThread(); - String tempFileName = binaryName + ".tmp." + - stageName + "." + - System.currentTimeMillis() + "." + - 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 - - log.getLogger().println("[JFrogCliDownloader] Temporary download file: " + temporaryDownloadFile.getAbsolutePath()); - log.getLogger().println("[JFrogCliDownloader] Final CLI executable: " + finalCliExecutable.getAbsolutePath()); - + private void performAtomicDownload(ArtifactoryManager manager, String cliUrlSuffix, + File toolLocation, String artifactorySha256) throws IOException { + File finalCliExecutable = new File(toolLocation, binaryName); + File temporaryDownloadFile = null; try { - // Download to temporary file - log.getLogger().println("[JFrogCliDownloader] Downloading to temporary file"); - File downloadResponse = 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()); + temporaryDownloadFile = downloadToTemp(manager, cliUrlSuffix, toolLocation); + log.getLogger().println("[JFrogCliDownloader] Moving to final location: " + finalCliExecutable.getAbsolutePath()); + moveFileWithRetry(temporaryDownloadFile, finalCliExecutable); + finalizeInstall(finalCliExecutable, toolLocation, artifactorySha256); + log.getLogger().println("[JFrogCliDownloader] Download and installation completed successfully"); + } catch (Exception e) { + log.getLogger().println("[JFrogCliDownloader] Download failed, cleaning up temporary file"); + cleanupTempFile(temporaryDownloadFile); + throw e; + } + } + + /** + * Performs atomic download for upgrade scenario with graceful fallback. + * 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 + * @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 { + File finalCliExecutable = new File(toolLocation, binaryName); + File temporaryDownloadFile = null; + try { + temporaryDownloadFile = downloadToTemp(manager, cliUrlSuffix, toolLocation); + log.getLogger().println("[JFrogCliDownloader] Attempting to replace existing CLI: " + finalCliExecutable.getAbsolutePath()); + boolean moveSucceeded = tryMoveFileForUpgrade(temporaryDownloadFile, finalCliExecutable); + if (!moveSucceeded) { + 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; } - - long fileSize = temporaryDownloadFile.length(); - if (fileSize == 0) { - throw new IOException("Downloaded file is empty: " + temporaryDownloadFile.getAbsolutePath()); + finalizeInstall(finalCliExecutable, toolLocation, artifactorySha256); + return true; + } catch (IOException e) { + 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); + return false; } - - log.getLogger().println("[JFrogCliDownloader] Download verified: " + (fileSize / 1024 / 1024) + "MB"); - - // Atomic move to final location (only delete existing if move will succeed) - 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()); + 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)) { + if (!isWindowsTarget()) { + throw e; } + log.getLogger().println("[JFrogCliDownloader] Target file is locked: " + e.getMessage()); + return false; } - - // 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()); + 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) { + Throwable current = e; + while (current != null) { + if (current instanceof AccessDeniedException) { + return true; } - - // 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()); + 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 message.contains("being used by another process") || + message.contains("Access is denied") || + message.contains("cannot access the file") || + message.contains("The process cannot access the file"); + } + + /** + * 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); + 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); + } + retryDelayMs *= 2; + } else { + throw new IOException("Failed to move file from " + source.getAbsolutePath() + + " to " + target.getAbsolutePath() + + " after " + attempt + " attempts: " + e.getMessage(), e); + } } - - // Create SHA256 verification file - log.getLogger().println("[JFrogCliDownloader] Creating SHA256 verification file"); - createSha256File(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; } } @@ -191,9 +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 { - File file = new File(toolLocation, SHA256_FILE_NAME); - Files.write(file.toPath(), artifactorySha256.getBytes(StandardCharsets.UTF_8)); + if (StringUtils.isBlank(artifactorySha256)) { + return; + } + 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); } /** @@ -201,11 +453,16 @@ 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. */ 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) download the tool. if (artifactorySha256.isEmpty()) { return true; } @@ -215,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); } @@ -229,7 +492,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(); } } @@ -258,4 +523,11 @@ private String getStageNameFromThread() { return "unknown"; } } + + /** + * Determine whether the target CLI binary is Windows. + */ + private boolean isWindowsTarget() { + return binaryName != null && binaryName.toLowerCase().endsWith(".exe"); + } } 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 af99e115..a0c49179 100644 --- a/src/test/java/io/jenkins/plugins/jfrog/CliEnvConfiguratorTest.java +++ b/src/test/java/io/jenkins/plugins/jfrog/CliEnvConfiguratorTest.java @@ -53,17 +53,8 @@ public void configEncryptionTest() throws IOException, InterruptedException { JFrogCliConfigEncryption configEncryption = new JFrogCliConfigEncryption(envVars); assertTrue(configEncryption.shouldEncrypt()); assertEquals(32, configEncryption.getKey().length()); - - File jfrogHomeDir = tempFolder.newFolder("jfrog-home-enc"); - FilePath jfrogHomeTempDir = new FilePath(jfrogHomeDir); - invokeConfigureCliEnv(jfrogHomeTempDir, configEncryption); - // The encryption key file is created in jfrogHomeTempDir/encryption/ to work in Docker containers - String keyFilePath = envVars.get(JFROG_CLI_ENCRYPTION_KEY); - assertNotNull(keyFilePath); - assertTrue(keyFilePath.startsWith(jfrogHomeDir.getAbsolutePath())); - assertTrue(keyFilePath.contains("encryption")); - assertTrue(keyFilePath.endsWith(".key")); - assertEquals(keyFilePath, configEncryption.getKeyFilePath()); + invokeConfigureCliEnv(new FilePath(tempFolder.newFolder("encryption-test")), configEncryption); + assertEnv(envVars, JFROG_CLI_ENCRYPTION_KEY, configEncryption.getKeyFilePath()); } @Test 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' } + } + } + } + } +}