Skip to content

Commit 104d3a7

Browse files
authored
Use latest cli version by default (#576)
* Use latest cli version by default * Fix e2e's
1 parent ebb4dd7 commit 104d3a7

File tree

7 files changed

+266
-11
lines changed

7 files changed

+266
-11
lines changed

jfrog-tasks-utils/utils.js

Lines changed: 115 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,117 @@ const semver = require('semver');
1010
const fileName = getCliExecutableName();
1111
const jfrogCliToolName = 'jf';
1212
const cliPackage = 'jfrog-cli-' + getArchitecture();
13-
const defaultJfrogCliVersion = '2.85.0';
13+
const fallbackCliVersion = '2.89.0';
14+
let defaultJfrogCliVersion = null;
15+
16+
/**
17+
* Executes an HTTP request with retry logic for 5xx errors.
18+
* @param {string} method - HTTP method (GET, POST, etc.)
19+
* @param {string} url - Request URL
20+
* @param {object} options - Request options (timeout, headers, etc.)
21+
* @param {number} maxRetries - Maximum number of retry attempts (default: 3)
22+
* @param {number} retryDelay - Delay between retries in ms (default: 1000)
23+
* @returns {object} Response object from syncRequest on success
24+
* @throws {Error} If all retries fail (network errors or 5xx responses)
25+
*/
26+
function syncRequestWithRetry(method, url, options = {}, maxRetries = 3, retryDelay = 1000) {
27+
let errorToThrow = null;
28+
let lastResponse = null;
29+
30+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
31+
try {
32+
const response = syncRequest(method, url, options);
33+
// Retry on 5xx server errors
34+
if (response?.statusCode >= 500 && response?.statusCode < 600) {
35+
console.warn(`Attempt ${attempt}/${maxRetries}: Server error ${response?.statusCode} for ${url}`);
36+
lastResponse = response;
37+
} else {
38+
return response;
39+
}
40+
} catch (err) {
41+
console.warn(`Attempt ${attempt}/${maxRetries}: Request failed for ${url} - ${err?.message}`);
42+
errorToThrow = err;
43+
}
44+
45+
if (attempt < maxRetries) {
46+
// Blocking delay before retry
47+
const waitUntil = Date.now() + retryDelay;
48+
while (Date.now() < waitUntil) { /* wait */ }
49+
}
50+
}
51+
52+
// All retries exhausted - throw error for both network failures and 5xx responses
53+
if (errorToThrow) {
54+
throw errorToThrow;
55+
}
56+
if (lastResponse) {
57+
throw new Error(`Server error ${lastResponse.statusCode} after ${maxRetries} retries for ${url}`);
58+
}
59+
}
60+
61+
/**
62+
* Checks if the CLI binary exists on releases.jfrog.io for the given version.
63+
* @param {string} version - The CLI version to check
64+
* @returns {boolean} True if binary exists, false otherwise
65+
*/
66+
function isCliBinaryAvailable(version) {
67+
const binaryUrl = `https://releases.jfrog.io/artifactory/jfrog-cli/v2-jf/${version}/${cliPackage}/${fileName}`;
68+
try {
69+
console.log('Verifying CLI binary availability at: ' + binaryUrl);
70+
const res = syncRequestWithRetry('HEAD', binaryUrl, { timeout: 5000 });
71+
return res?.statusCode === 200;
72+
} catch (err) {
73+
console.warn('Failed to verify CLI binary availability: ' + err?.message);
74+
return false;
75+
}
76+
}
77+
78+
/**
79+
* Fetches the latest available JFrog CLI version from GitHub releases with retry mechanism.
80+
* Validates that the binary is available on releases.jfrog.io before returning.
81+
* If the latest release binary isn't available, falls back to the previous release.
82+
* Called once during module initialization. Result is cached in defaultJfrogCliVersion.
83+
* @returns {string} The CLI version (e.g., '2.89.0') or fallback if fetch fails or no binary available
84+
*/
85+
function fetchLatestCliVersion() {
86+
try {
87+
console.log('Fetching JFrog CLI releases from https://api.github.com/repos/jfrog/jfrog-cli/releases');
88+
const res = syncRequestWithRetry('GET', 'https://api.github.com/repos/jfrog/jfrog-cli/releases?per_page=3', {
89+
headers: { 'User-Agent': 'jfrog-azure-devops-extension' },
90+
timeout: 5000,
91+
});
92+
if (res.statusCode === 200) {
93+
const releases = JSON.parse(res.getBody('utf8'));
94+
console.log('Fetched ' + releases?.length ?? 0 + ' JFrog CLI releases');
95+
96+
if (!releases || releases.length === 0) {
97+
console.warn('No JFrog CLI releases found, using fallback: ' + fallbackCliVersion);
98+
return fallbackCliVersion;
99+
}
100+
101+
// Try each release until we find one with an available binary
102+
for (const release of releases) {
103+
const version = release?.name;
104+
console.log('Checking CLI version: ' + version);
105+
106+
if (version && isCliBinaryAvailable(version)) {
107+
console.log('CLI binary verified available for version: ' + version);
108+
return version;
109+
}
110+
console.warn('CLI binary not yet available for version: ' + version);
111+
}
112+
console.warn('No CLI binaries available for last 3 releases, using fallback: ' + fallbackCliVersion);
113+
return fallbackCliVersion;
114+
}
115+
console.warn('Unexpected status code: ' + res.statusCode + ', using fallback version: ' + fallbackCliVersion);
116+
} catch (err) {
117+
console.warn('Failed to fetch JFrog CLI releases, due to error: ' + err?.message + ', using fallback: ' + fallbackCliVersion);
118+
}
119+
return fallbackCliVersion;
120+
}
121+
122+
// Fetch and cache the CLI version during module initialization
123+
defaultJfrogCliVersion = fetchLatestCliVersion();
14124

15125
/**
16126
* Safely constructs the JFrog tools directory path, handling potential issues with Agent.ToolsDirectory
@@ -69,6 +179,9 @@ const jfrogCliConfigUseCommand = 'c use';
69179
let runTaskCbk = null;
70180

71181
module.exports = {
182+
syncRequestWithRetry: syncRequestWithRetry,
183+
isCliBinaryAvailable: isCliBinaryAvailable,
184+
fetchLatestCliVersion: fetchLatestCliVersion,
72185
executeCliTask: executeCliTask,
73186
executeCliCommand: executeCliCommand,
74187
downloadCli: downloadCli,
@@ -112,6 +225,7 @@ module.exports = {
112225
configureDefaultXrayServer: configureDefaultXrayServer,
113226
minCustomCliVersion: minCustomCliVersion,
114227
defaultJfrogCliVersion: defaultJfrogCliVersion,
228+
fallbackCliVersion: fallbackCliVersion,
115229
pipelineRequestedCliVersionEnv: pipelineRequestedCliVersionEnv,
116230
taskSelectedCliVersionEnv: taskSelectedCliVersionEnv,
117231
extractorsRemoteEnv: extractorsRemoteEnv,

tasks/JFrogConan/conanUtils.js

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
const tl = require('azure-pipelines-task-lib/task');
22
const { v4: uuid } = require('uuid');
3-
const tmpdir = require('os').tmpdir;
4-
const EOL = require('os').EOL;
3+
const os = require('os');
4+
const tmpdir = os.tmpdir;
5+
const EOL = os.EOL;
56
const fs = require('fs-extra');
67
const join = require('path').join;
78
const createHash = require('crypto').createHash;
@@ -14,7 +15,23 @@ const BUILD_INFO_BUILD_NAME = 'name';
1415
const BUILD_INFO_BUILD_NUMBER = 'number';
1516
const BUILD_INFO_BUILD_STARTED = 'started';
1617
const BUILD_INFO_FILE_NAME = 'generatedBuildInfo';
17-
const BUILD_TEMP_PATH = 'jfrog/builds';
18+
19+
/**
20+
* Get the JFrog CLI build temp directory path segments.
21+
* On Windows: jfrog-<COMPUTERNAME>/<USERNAME>
22+
* On macOS/Linux: jfrog-<USERNAME>
23+
* @returns {string[]} Array of path segments to join
24+
*/
25+
function getJfrogBuildDirSegments() {
26+
const username = os.userInfo().username;
27+
if (process.platform === 'win32') {
28+
// On Windows, the CLI uses: jfrog-<COMPUTERNAME>/<USERNAME>/builds
29+
const hostname = os.hostname();
30+
return [`jfrog-${hostname}`, username];
31+
}
32+
// On macOS/Linux: jfrog-<USERNAME>/builds
33+
return [`jfrog-${username}`];
34+
}
1835

1936
/**
2037
* Execute Artifactory Conan Task
@@ -441,7 +458,10 @@ function readTimestampFromBuildPartialDetailsFile(buildDetailsFile) {
441458
function getCliPartialsBuildDir(buildName, buildNumber) {
442459
const buildId = buildName + '_' + buildNumber + '_' + '';
443460
const hexId = createHash('sha256').update(buildId).digest('hex');
444-
return join(tmpdir(), BUILD_TEMP_PATH, hexId);
461+
// Use separate path segments to ensure correct path separators on all platforms
462+
// On Windows: <tmpdir>/jfrog-<COMPUTERNAME>/<USERNAME>/builds/<hash>
463+
// On macOS/Linux: <tmpdir>/jfrog-<USERNAME>/builds/<hash>
464+
return join(tmpdir(), ...getJfrogBuildDirSegments(), 'builds', hexId);
445465
}
446466

447467
module.exports = {

tests/resources/conanTask/files/conan-min/conanfile.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ class ConanminConan(ConanFile):
1414
exports_sources = "src/*"
1515

1616
def requirements(self):
17-
self.requires("boost/[>=1.77]")
17+
# Using fmt - a header-only library that avoids CMake compatibility issues
18+
self.requires("fmt/[>=8.0.1]")
1819

1920
def build(self):
2021
cmake = CMake(self)

tests/resources/maven/resources/pom.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
<plugin>
4545
<groupId>org.apache.maven.plugins</groupId>
4646
<artifactId>maven-jar-plugin</artifactId>
47+
<version>3.4.1</version>
4748
<executions>
4849
<execution>
4950
<goals>

tests/testUtils.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import * as mockRun from 'azure-pipelines-task-lib/mock-run';
22
import * as tl from 'azure-pipelines-task-lib/task';
33
import { join, basename } from 'path';
44
import * as fs from 'fs-extra';
5-
import rimraf from 'rimraf';
5+
import { rimrafSync } from 'rimraf';
66
import * as syncRequest from 'sync-request';
77
import * as assert from 'assert';
88
import NullWritable from 'null-writable';
@@ -114,7 +114,7 @@ export function runTaskForService(testMain: string, variables: any, inputs: any)
114114

115115
export function recreateTestDataDir(): void {
116116
if (fs.existsSync(testDataDir)) {
117-
rimraf.sync(testDataDir);
117+
rimrafSync(testDataDir);
118118
}
119119
fs.mkdirSync(testDataDir);
120120
}
@@ -145,7 +145,7 @@ export function cleanToolCache(): void {
145145
export function cleanUpAllTests(): void {
146146
if (fs.existsSync(testDataDir)) {
147147
try {
148-
rimraf.sync(testDataDir);
148+
rimrafSync(testDataDir);
149149
} catch (err) {
150150
console.warn('Tests cleanup issue: ' + err);
151151
}

tests/tests.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ describe('JFrog Artifactory Extension Tests', (): void => {
5454
repoKeys.repo1 +
5555
'/' +
5656
' --url=' +
57-
jfrogUtils.quote(process.env.ADO_JFROG_PLATFORM_URL + 'artifactory') +
57+
jfrogUtils.quote(process.env.ADO_JFROG_PLATFORM_URL ?? '') +
5858
' --user=' +
5959
jfrogUtils.quote(process.env.ADO_JFROG_PLATFORM_USERNAME ?? '') +
6060
' --password=' +
@@ -1279,6 +1279,18 @@ function testInitCliPartialsBuildDir(): void {
12791279
runBuildCommand('bc', testsBuildName, testBuildNumber);
12801280
}
12811281

1282+
function getJfrogCliPath(): string {
1283+
const versions: string[] = toolLib.findLocalToolVersions('jf');
1284+
if (versions.length === 0) {
1285+
// Fallback to PATH-based execution if CLI is not in tool cache
1286+
return 'jf';
1287+
}
1288+
const cliDir: string = toolLib.findLocalTool('jf', versions[0]);
1289+
const executableName: string = process.platform.startsWith('win') ? 'jf.exe' : 'jf';
1290+
return join(cliDir, executableName);
1291+
}
1292+
12821293
function runBuildCommand(command: string, buildName: string, buildNumber: string): void {
1283-
jfrogUtils.executeCliCommand('jf rt ' + command + ' "' + buildName + '" ' + buildNumber, TestUtils.testDataDir);
1294+
const cliPath: string = getJfrogCliPath();
1295+
jfrogUtils.executeCliCommand(cliPath + ' rt ' + command + ' "' + buildName + '" ' + buildNumber, TestUtils.testDataDir);
12841296
}

tests/utilsTests.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,21 @@ import * as assert from 'assert';
44
// Use require to get the actual module with latest exports
55
import * as jfrogUtils from '@jfrog/tasks-utils';
66

7+
// Type declarations for the new exported functions
8+
declare module '@jfrog/tasks-utils' {
9+
export function syncRequestWithRetry(
10+
method: string,
11+
url: string,
12+
options?: object,
13+
maxRetries?: number,
14+
retryDelay?: number,
15+
): { statusCode: number; getBody(encoding: string): string };
16+
export function isCliBinaryAvailable(version: string): boolean;
17+
export function fetchLatestCliVersion(): string;
18+
export const defaultJfrogCliVersion: string;
19+
export const fallbackCliVersion: string;
20+
}
21+
722
/**
823
* Simulates the platformUrl resolution logic from utils.js lines 441-449:
924
*
@@ -134,4 +149,96 @@ describe('Utils Unit Tests', (): void => {
134149
);
135150
});
136151
});
152+
153+
describe('syncRequestWithRetry', (): void => {
154+
it('should return response on successful request (2xx)', (): void => {
155+
const response: { statusCode: number } = jfrogUtils.syncRequestWithRetry('GET', 'https://httpstat.us/200', { timeout: 5000 });
156+
assert.strictEqual(response.statusCode, 200);
157+
});
158+
159+
it('should return response on 4xx client error without retry', (): void => {
160+
const response: { statusCode: number } = jfrogUtils.syncRequestWithRetry('GET', 'https://httpstat.us/404', { timeout: 5000 });
161+
assert.strictEqual(response.statusCode, 404);
162+
});
163+
164+
it('should throw error after retries exhausted on 5xx server error', (): void => {
165+
assert.throws(
166+
(): void => {
167+
jfrogUtils.syncRequestWithRetry('GET', 'https://httpstat.us/503', { timeout: 5000 }, 2, 100);
168+
},
169+
/Server error 503 after 2 retries/,
170+
);
171+
});
172+
173+
it('should throw error on network failure after retries', (): void => {
174+
assert.throws(
175+
(): void => {
176+
jfrogUtils.syncRequestWithRetry('GET', 'https://invalid.domain.that.does.not.exist.example', { timeout: 1000 }, 2, 100);
177+
},
178+
Error,
179+
);
180+
});
181+
});
182+
183+
describe('isCliBinaryAvailable', (): void => {
184+
it('should return true for a known valid CLI version', (): void => {
185+
// Use a known stable version that should always be available
186+
const result: boolean = jfrogUtils.isCliBinaryAvailable('2.50.0');
187+
assert.strictEqual(result, true);
188+
});
189+
190+
it('should return false for a non-existent CLI version', (): void => {
191+
const result: boolean = jfrogUtils.isCliBinaryAvailable('0.0.1');
192+
assert.strictEqual(result, false);
193+
});
194+
195+
it('should return false for an invalid version format', (): void => {
196+
const result: boolean = jfrogUtils.isCliBinaryAvailable('invalid-version');
197+
assert.strictEqual(result, false);
198+
});
199+
});
200+
201+
describe('fetchLatestCliVersion', (): void => {
202+
it('should return a valid semver version string', (): void => {
203+
const version: string = jfrogUtils.fetchLatestCliVersion();
204+
// Version should match semver pattern (e.g., "2.89.0")
205+
assert.match(version, /^\d+\.\d+\.\d+$/);
206+
});
207+
208+
it('should return version >= 2.50.0 (reasonable minimum)', (): void => {
209+
const version: string = jfrogUtils.fetchLatestCliVersion();
210+
const [major, minor]: number[] = version.split('.').map(Number);
211+
assert.ok(major >= 2, `Major version ${major} should be >= 2`);
212+
if (major === 2) {
213+
assert.ok(minor >= 50, `Minor version ${minor} should be >= 50 for major version 2`);
214+
}
215+
});
216+
});
217+
218+
describe('defaultJfrogCliVersion', (): void => {
219+
it('should be initialized with a valid version', (): void => {
220+
assert.ok(jfrogUtils.defaultJfrogCliVersion, 'defaultJfrogCliVersion should be defined');
221+
assert.match(jfrogUtils.defaultJfrogCliVersion, /^\d+\.\d+\.\d+$/);
222+
});
223+
224+
it('should match the result of fetchLatestCliVersion', (): void => {
225+
// Since defaultJfrogCliVersion is set at module load, it should match fetchLatestCliVersion
226+
// unless there was a failure (in which case both would use fallback)
227+
const fetchedVersion: string = jfrogUtils.fetchLatestCliVersion();
228+
assert.strictEqual(jfrogUtils.defaultJfrogCliVersion, fetchedVersion);
229+
});
230+
});
231+
232+
describe('fallbackCliVersion', (): void => {
233+
it('should be a valid semver version string', (): void => {
234+
assert.ok(jfrogUtils.fallbackCliVersion, 'fallbackCliVersion should be defined');
235+
assert.match(jfrogUtils.fallbackCliVersion, /^\d+\.\d+\.\d+$/);
236+
});
237+
238+
it('should have an available binary on releases.jfrog.io', (): void => {
239+
// The fallback version should always have its binary available
240+
const result: boolean = jfrogUtils.isCliBinaryAvailable(jfrogUtils.fallbackCliVersion);
241+
assert.strictEqual(result, true, `Fallback version ${jfrogUtils.fallbackCliVersion} should have available binary`);
242+
});
243+
});
137244
});

0 commit comments

Comments
 (0)