diff --git a/README.md b/README.md
index c24e738b..e8d18f92 100644
--- a/README.md
+++ b/README.md
@@ -5,10 +5,6 @@ ci-sauce
This folder contains the common code for the Bamboo and Jenkins Sauce OnDemand plugins.
-To update sauce connect, run the download script, src/main/resources/download.sh
-This will download all the required files, you just need to update git.
-Make sure the jq (https://stedolan.github.io/jq/) app is installed in your path
-
To build the plugin, you will need [Maven 2](http://maven.apache.org).
To build (compile,test,jar) the plugin run:
diff --git a/pom.xml b/pom.xml
index ecf7014b..7f5d1406 100644
--- a/pom.xml
+++ b/pom.xml
@@ -5,7 +5,7 @@
com.saucelabs
ci-sauce
- 1.177-SNAPSHOT
+ 2.0-SNAPSHOT
jar
@@ -153,7 +153,7 @@
true
true
lib/
- com.saucelabs.ci.sauceconnect.SauceConnectFourManager
+ com.saucelabs.ci.sauceconnect.SauceConnectManager
diff --git a/src/main/java/com/saucelabs/ci/sauceconnect/AbstractSauceTunnelManager.java b/src/main/java/com/saucelabs/ci/sauceconnect/AbstractSauceTunnelManager.java
index f21b0ee1..959d5bf4 100644
--- a/src/main/java/com/saucelabs/ci/sauceconnect/AbstractSauceTunnelManager.java
+++ b/src/main/java/com/saucelabs/ci/sauceconnect/AbstractSauceTunnelManager.java
@@ -11,6 +11,10 @@
import java.io.InputStreamReader;
import java.io.PrintStream;
import java.net.ServerSocket;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Arrays;
@@ -25,9 +29,10 @@
import org.json.JSONException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import org.json.JSONObject;
/**
- * Provides common logic for the invocation of Sauce Connect v3 and v4 processes. The class
+ * Provides common logic for the invocation of Sauce Connect processes. The class
* maintains a cache of {@link Process } instances mapped against the corresponding Sauce user which
* invoked Sauce Connect.
*
@@ -49,6 +54,7 @@ public abstract class AbstractSauceTunnelManager implements SauceTunnelManager {
private SauceREST sauceRest;
private SauceConnectEndpoint scEndpoint;
+ private SCMonitor scMonitor;
private AtomicInteger launchAttempts = new AtomicInteger(0);
@@ -76,10 +82,8 @@ public static String getTunnelName(String options, String defaultValue) {
String[] split = options.split(" ");
for (int i = 0; i < split.length; i++) {
String option = split[i];
- // Handle old tunnel-identifier option and well as new tunnel-name
if (option.equals("-i")
- || option.equals("--tunnel-name")
- || option.equals("--tunnel-identifier")) {
+ || option.equals("--tunnel-name")) {
// next option is name
name = split[i + 1];
}
@@ -116,6 +120,10 @@ public void setSauceRest(SauceREST sauceRest) {
this.scEndpoint = sauceRest.getSauceConnectEndpoint();
}
+ public void setSCMonitor(SCMonitor scMonitor) {
+ this.scMonitor = scMonitor;
+ }
+
/**
* Closes the Sauce Connect process
*
@@ -529,57 +537,28 @@ public Process openConnection(
try {
Semaphore semaphore = new Semaphore(1);
semaphore.acquire();
- StreamGobbler errorGobbler = makeErrorGobbler(printStream, process.getErrorStream());
- errorGobbler.start();
- SystemOutGobbler outputGobbler =
- makeOutputGobbler(printStream, process.getInputStream(), semaphore);
- outputGobbler.start();
+
+ SCMonitor scMonitor;
+ if ( this.scMonitor != null ) {
+ scMonitor = this.scMonitor;
+ } else {
+ scMonitor = new SCMonitor("SCMonitor", port, LOGGER);
+ }
+
+ scMonitor.setSemaphore(semaphore);
+ scMonitor.start();
boolean sauceConnectStarted = semaphore.tryAcquire(3, TimeUnit.MINUTES);
if (sauceConnectStarted) {
- if (outputGobbler.isFailed()) {
+ if (scMonitor.isFailed()) {
String message = "Error launching Sauce Connect";
logMessage(printStream, message);
// ensure that Sauce Connect process is closed
closeSauceConnectProcess(printStream, process);
throw new SauceConnectDidNotStartException(message);
- } else if (outputGobbler.isCantLockPidfile()) {
- logMessage(
- printStream,
- "Sauce Connect can't lock pidfile, attempting to close open Sauce Connect processes");
- // close any open Sauce Connect processes
- for (Process openedProcess : openedProcesses) {
- openedProcess.destroy();
- }
-
- // Sauce Connect failed to start, possibly because although process has been killed by
- // the plugin, it still remains active for a few seconds
- if (launchAttempts.get() < 3) {
- // wait for a few seconds to let the process finish closing
- Thread.sleep(5000);
- // increment launch attempts variable
- launchAttempts.incrementAndGet();
-
- // call openConnection again to see if the process has closed
- return openConnection(
- username,
- apiKey,
- dataCenter,
- port,
- sauceConnectJar,
- options,
- printStream,
- verboseLogging,
- sauceConnectPath);
- } else {
- // we've tried relaunching Sauce Connect 3 times
- throw new SauceConnectDidNotStartException(
- "Unable to start Sauce Connect, please see the Sauce Connect log");
- }
-
} else {
// everything okay, continue the build
- String provisionedTunnelId = outputGobbler.getTunnelId();
+ String provisionedTunnelId = scMonitor.getTunnelId();
if (provisionedTunnelId != null) {
tunnelInformation.setTunnelId(provisionedTunnelId);
waitForReadiness(provisionedTunnelId);
@@ -647,16 +626,6 @@ private void waitForReadiness(String tunnelId) {
}
}
- public SystemErrorGobbler makeErrorGobbler(PrintStream printStream, InputStream errorStream) {
- return new SystemErrorGobbler("ErrorGobbler", errorStream, printStream);
- }
-
- public SystemOutGobbler makeOutputGobbler(
- PrintStream printStream, InputStream inputStream, Semaphore semaphore) {
- return new SystemOutGobbler(
- "OutputGobbler", inputStream, semaphore, printStream, getSauceStartedMessage());
- }
-
private TunnelInformation getTunnelInformation(String name) {
if (name == null) {
return null;
@@ -701,12 +670,11 @@ private String activeTunnelID(String username, String tunnelName) {
* @param args the initial Sauce Connect command line args
* @param username name of the user which launched Sauce Connect
* @param apiKey the access key for the Sauce user
- * @param port the port that Sauce Connect should be launched on
* @param options command line args specified by the user
* @return String array representing the command line args to be used to launch Sauce Connect
*/
protected abstract String[] generateSauceConnectArgs(
- String[] args, String username, String apiKey, int port, String options);
+ String[] args, String username, String apiKey, String options);
protected abstract String[] addExtraInfo(String[] args);
@@ -719,11 +687,6 @@ public String getSauceConnectWorkingDirectory() {
public abstract File getSauceConnectLogFile(String options);
- /**
- * @return Text which indicates that Sauce Connect has started
- */
- protected abstract String getSauceStartedMessage();
-
/** Base exception class which is thrown if an error occurs launching Sauce Connect. */
public static class SauceConnectException extends IOException {
@@ -747,49 +710,6 @@ public SauceConnectDidNotStartException(String message) {
}
}
- /** Handles receiving and processing the output of an external process. */
- protected abstract class StreamGobbler extends Thread {
- private final PrintStream printStream;
- private final InputStream is;
-
- public StreamGobbler(String name, InputStream is, PrintStream printStream) {
- super(name);
- this.is = is;
- this.printStream = printStream;
- }
-
- /** Opens a BufferedReader over the input stream, reads and processes each line. */
- public void run() {
- try {
- InputStreamReader isr = new InputStreamReader(is);
- BufferedReader br = new BufferedReader(isr);
- String line;
- while ((line = br.readLine()) != null) {
- processLine(line);
- }
- } catch (IOException ioe) {
- // ignore stream closed errors
- if (!(ioe.getMessage().equalsIgnoreCase("stream closed"))) {
- ioe.printStackTrace();
- }
- }
- }
-
- /**
- * Processes a line of output received by the stream gobbler.
- *
- * @param line line to process
- */
- protected void processLine(String line) {
- if (!quietMode) {
- if (printStream != null) {
- printStream.println(line);
- }
- LOGGER.info(line);
- }
- }
- }
-
private int findFreePort() throws SauceConnectException {
try (ServerSocket socket = new ServerSocket(0)) {
return socket.getLocalPort();
@@ -798,78 +718,97 @@ private int findFreePort() throws SauceConnectException {
}
}
- /** Handles processing Sauce Connect output sent to stdout. */
- public class SystemOutGobbler extends StreamGobbler {
-
- private final Semaphore semaphore;
- private final String startedMessage;
+ /** Monitors SC Process via HTTP API */
+ public class SCMonitor extends Thread {
+ private Semaphore semaphore;
+ private final int port;
+ private final Logger LOGGER;
private String tunnelId;
private boolean failed;
- private boolean cantLockPidfile;
+ private boolean apiResponse;
- public SystemOutGobbler(
+ private HttpClient client = HttpClient.newHttpClient();
+ private static final int sleepTime = 1000;
+
+ public SCMonitor(
String name,
- InputStream is,
- final Semaphore semaphore,
- PrintStream printStream,
- String startedMessage) {
- super(name, is, printStream);
- this.semaphore = semaphore;
- this.startedMessage = startedMessage;
+ final int port,
+ final Logger logger) {
+ super(name);
+ this.port = port;
+ this.LOGGER = logger;
}
- /**
- * {@inheritDoc}
- *
- *
If the line contains the Sauce Connect started message, then release the semaphone, which
- * will allow the build to resume.
- *
- * @param line Line being processed
- */
- @Override
- protected void processLine(String line) {
- super.processLine(line);
-
- if (StringUtils.containsIgnoreCase(line, "can't lock pidfile")) {
- // this message is generated from Sauce Connect when the pidfile can't be locked, indicating
- // that SC is still running
- cantLockPidfile = true;
- }
-
- if (StringUtils.containsIgnoreCase(line, "Tunnel ID:")) {
- tunnelId = StringUtils.substringAfter(line, "Tunnel ID: ").trim();
- }
- if (StringUtils.containsIgnoreCase(line, "Provisioned tunnel:")) {
- tunnelId = StringUtils.substringAfter(line, "Provisioned tunnel:").trim();
- }
- if (StringUtils.containsIgnoreCase(line, "Goodbye")) {
- failed = true;
- }
- if (StringUtils.containsIgnoreCase(line, startedMessage) || failed || cantLockPidfile) {
- // unlock processMonitor
- semaphore.release();
- }
+ public void setSemaphore(Semaphore semaphore) {
+ this.semaphore = semaphore;
}
public String getTunnelId() {
- return tunnelId;
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create(String.format("http://localhost:%d/info", port)))
+ .GET()
+ .build();
+ try {
+ HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString());
+ String responseBody = response.body();
+ JSONObject jsonObject = new JSONObject(responseBody);
+ if (jsonObject.has("tunnel_id")) {
+ return jsonObject.getString("tunnel_id");
+ }
+ } catch (Exception e) {
+ this.LOGGER.info("Failed to get tunnel id", e);
+ return null;
+ }
+ this.LOGGER.info("Failed to get tunnel id");
+ return null;
}
public boolean isFailed() {
return failed;
}
- public boolean isCantLockPidfile() {
- return cantLockPidfile;
+ public void run() {
+ while (true) {
+ pollEndpoint();
+ if (this.semaphore.availablePermits() > 0) {
+ return;
+ }
+
+ try {
+ Thread.sleep(sleepTime);
+ } catch ( java.lang.InterruptedException e ) {
+ return;
+ }
+ }
}
- }
- /** Handles processing Sauce Connect output sent to stderr. */
- public class SystemErrorGobbler extends StreamGobbler {
+ private void pollEndpoint() {
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create(String.format("http://localhost:%d/readyz", port)))
+ .GET()
+ .build();
- public SystemErrorGobbler(String name, InputStream is, PrintStream printStream) {
- super(name, is, printStream);
+ try {
+ HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString());
+
+ if (response.statusCode() == 200) {
+ this.apiResponse = true;
+ this.LOGGER.info("Got connected status");
+ semaphore.release();
+ } else if (response.statusCode() == 503) {
+ this.apiResponse = true;
+ }
+ } catch ( Exception e ) {
+ if ( this.apiResponse ) {
+ // We've had a successful API endpoint read, but then it stopped responding, which means the process failed to start
+ this.failed = true;
+ this.LOGGER.warn("API stopped responding", e);
+ semaphore.release();
+ }
+ }
+
+ this.LOGGER.trace("No API response yet");
}
}
}
diff --git a/src/main/java/com/saucelabs/ci/sauceconnect/SauceConnectFourManager.java b/src/main/java/com/saucelabs/ci/sauceconnect/SauceConnectManager.java
similarity index 74%
rename from src/main/java/com/saucelabs/ci/sauceconnect/SauceConnectFourManager.java
rename to src/main/java/com/saucelabs/ci/sauceconnect/SauceConnectManager.java
index a84a2fbf..447e22ad 100755
--- a/src/main/java/com/saucelabs/ci/sauceconnect/SauceConnectFourManager.java
+++ b/src/main/java/com/saucelabs/ci/sauceconnect/SauceConnectManager.java
@@ -31,26 +31,27 @@
import java.net.URL;
/**
- * Handles launching Sauce Connect v4 (binary executable).
+ * Handles launching Sauce Connect (binary executable).
*
* @author Ross Rowe
*/
-public class SauceConnectFourManager extends AbstractSauceTunnelManager
+public class SauceConnectManager extends AbstractSauceTunnelManager
implements SauceTunnelManager {
private boolean useLatestSauceConnect = false;
/** Remove all created files and directories on exit */
private boolean cleanUpOnExit;
- /** System which runs SauceConnect, this info is added to '--extra-info' argument */
+ /** System which runs SauceConnect, this info is added to '--metadata runner=' argument */
private final String runner;
/** Represents the operating system-specific Sauce Connect binary. */
public enum OperatingSystem {
- OSX("osx", "zip", null, UNIX_TEMP_DIR),
- WINDOWS("win32", "zip", null, WINDOWS_TEMP_DIR, "sc.exe"),
- LINUX("linux", "tar", "gz", UNIX_TEMP_DIR),
- LINUX_ARM64("linux-arm64", "tar", "gz", UNIX_TEMP_DIR);
+ OSX("darwin.all", "zip", null, UNIX_TEMP_DIR, "darwin"),
+ WINDOWS_AMD64("windows.x86_64", "zip", null, WINDOWS_TEMP_DIR, "windows-amd64", "sauce-connect.exe"),
+ WINDOWS_ARM64("windows.aarch64", "zip", null, WINDOWS_TEMP_DIR, "windows-arm64", "sauce-connect.exe"),
+ LINUX_AMD64("linux.x86_64", "tar", "gz", UNIX_TEMP_DIR, "linux-amd64"),
+ LINUX_ARM64("linux.aarch64", "tar", "gz", UNIX_TEMP_DIR, "linux-arm64");
private final String directoryEnding;
private final String archiveExtension;
@@ -58,27 +59,34 @@ public enum OperatingSystem {
private final String compressionAlgorithm;
private final String executable;
private final String tempDirectory;
+ private final String downloadPlatform;
OperatingSystem(
String directoryEnding, String archiveFormat, String compressionAlgorithm, String tempDirectory,
- String executable) {
+ String downloadPlatform, String executable) {
this.directoryEnding = directoryEnding;
this.archiveExtension = compressionAlgorithm == null ? archiveFormat : archiveFormat + "." + compressionAlgorithm;
this.archiveFormat = archiveFormat;
this.compressionAlgorithm = compressionAlgorithm;
- this.executable = "bin" + File.separatorChar + executable;
+ this.executable = executable;
this.tempDirectory = tempDirectory;
+ this.downloadPlatform = downloadPlatform;
}
OperatingSystem(String directoryEnding, String archiveFormat, String compressionAlgorithm,
- String tempDirectory) {
- this(directoryEnding, archiveFormat, compressionAlgorithm, tempDirectory, "sc");
+ String tempDirectory, String downloadPlatform) {
+ this(directoryEnding, archiveFormat, compressionAlgorithm, tempDirectory, downloadPlatform, "sc");
}
public static OperatingSystem getOperatingSystem() {
String os = System.getProperty("os.name").toLowerCase();
if (isWindows(os)) {
- return WINDOWS;
+ String arch = System.getProperty("os.arch").toLowerCase();
+
+ if (isArm(arch)) {
+ return WINDOWS_ARM64;
+ }
+ return WINDOWS_AMD64;
}
if (isMac(os)) {
return OSX;
@@ -89,7 +97,7 @@ public static OperatingSystem getOperatingSystem() {
if (isArm(arch)) {
return LINUX_ARM64;
}
- return LINUX;
+ return LINUX_AMD64;
}
throw new IllegalStateException("Unsupported OS: " + os);
}
@@ -111,7 +119,7 @@ private static boolean isArm(String arch) {
}
public String getDirectory(boolean useLatestSauceConnect) {
- return SAUCE_CONNECT + getVersion(useLatestSauceConnect) + '-' + directoryEnding;
+ return SAUCE_CONNECT_PREFIX + getVersion(useLatestSauceConnect) + '_' + directoryEnding;
}
public String getFileName(boolean useLatestSauceConnect) {
@@ -131,30 +139,29 @@ public String getDefaultSauceConnectLogDirectory() {
private static final String WINDOWS_TEMP_DIR = System.getProperty("java.io.tmpdir");
- /** Output from Sauce Connect process which indicates that it has been started. */
- private static final String SAUCE_CONNECT_4_STARTED =
- "Sauce Connect is up, you may start your tests";
-
- public static final String CURRENT_SC_VERSION = "4.9.1";
+ public static final String CURRENT_SC_VERSION = "5.2.1";
public static final LazyInitializer LATEST_SC_VERSION = new Builder, String>()
- .setInitializer(SauceConnectFourManager::getLatestSauceConnectVersion)
+ .setInitializer(SauceConnectManager::getLatestSauceConnectVersion)
.get();
- private static final String SAUCE_CONNECT = "sc-";
- public static final String SAUCE_CONNECT_4 = SAUCE_CONNECT + CURRENT_SC_VERSION;
+ private static final String SAUCE_CONNECT_PREFIX = "sauce-connect-";
+ public static final String SAUCE_CONNECT = SAUCE_CONNECT_PREFIX + CURRENT_SC_VERSION;
+
+ private static final int DEFAULT_API_PORT = 9000;
+ private int apiPort;
/** Constructs a new instance with quiet mode disabled. */
- public SauceConnectFourManager() {
+ public SauceConnectManager() {
this(false);
}
/**
* Constructs a new instance with quiet mode disabled.
*
- * @param runner System which runs SauceConnect, this info is added to '--extra-info' argument
+ * @param runner System which runs SauceConnect, this info is added to '--metadata runner=' argument
*/
- public SauceConnectFourManager(String runner) {
- this(false, runner);
+ public SauceConnectManager(String runner) {
+ this(false, runner, DEFAULT_API_PORT);
}
/**
@@ -162,25 +169,26 @@ public SauceConnectFourManager(String runner) {
*
* @param quietMode indicates whether Sauce Connect output should be suppressed
*/
- public SauceConnectFourManager(boolean quietMode) {
- this(quietMode, "jenkins");
+ public SauceConnectManager(boolean quietMode) {
+ this(quietMode, "jenkins", DEFAULT_API_PORT);
}
/**
* Constructs a new instance.
*
* @param quietMode indicates whether Sauce Connect output should be suppressed
- * @param runner System which runs SauceConnect, this info is added to '--extra-info' argument
+ * @param runner System which runs SauceConnect, this info is added to '--metadata runner=' argument
+ * @param apiPort Port the Sauce Connect process will listen on
*/
- public SauceConnectFourManager(boolean quietMode, String runner) {
+ public SauceConnectManager(boolean quietMode, String runner, int apiPort) {
super(quietMode);
this.runner = runner;
+ this.apiPort = DEFAULT_API_PORT;
}
/**
* @param username name of the user which launched Sauce Connect
- * @param apiKey api key corresponding to the user
- * @param port port which Sauce Connect should be launched on
+ * @param accessKey api key corresponding to the user
* @param sauceConnectJar File which contains the Sauce Connect executables (typically the CI
* plugin Jar file)
* @param options the command line options used to launch Sauce Connect
@@ -194,8 +202,8 @@ public SauceConnectFourManager(boolean quietMode, String runner) {
@Override
protected Process prepAndCreateProcess(
String username,
- String apiKey,
- int port,
+ String accessKey,
+ int apiPort,
File sauceConnectJar,
String options,
PrintStream printStream,
@@ -239,9 +247,13 @@ protected Process prepAndCreateProcess(
}
}
+ if ( apiPort != 0 ) {
+ this.apiPort = apiPort;
+ }
+
// although we are setting the working directory, we need to specify the full path to the exe
String[] args = {sauceConnectBinary.getPath()};
- args = generateSauceConnectArgs(args, username, apiKey, port, options);
+ args = generateSauceConnectArgs(args, username, accessKey, options);
args = addExtraInfo(args);
LOGGER.info("Launching Sauce Connect {} {}", getCurrentVersion(), hideSauceConnectCommandlineSecrets(args));
@@ -252,23 +264,35 @@ protected Process prepAndCreateProcess(
}
public String hideSauceConnectCommandlineSecrets(String[] args) {
- HashMap map = new HashMap<>();
- map.put("-k", "()\\w+-\\w+-\\w+-\\w+-\\w+");
- map.put("--api-key", "()\\w+-\\w+-\\w+-\\w+-\\w+");
- map.put("-w", "(\\S+:)\\S+");
- map.put("--proxy-userpwd", "(\\S+:)\\S+");
- map.put("-a", "(\\S+:\\d+:\\S+:)\\S+");
- map.put("--auth", "(\\S+:\\d+:\\S+:)\\S+");
+ var map = new HashMap();
+ map.put("-k", "^().*");
+ map.put("--access-key", "^().*");
+ map.put("-a", "^().*");
+ map.put("--auth", "^().*");
+ map.put("--api-basic-auth", "^([^:]*:).*");
+ map.put("-x", "^(.*:).*(@.*)");
+ map.put("--proxy", "^(.*:).*(@.*)");
String regexpForNextElement = null;
- List hiddenArgs = new ArrayList<>();
+
+ var replaceMap = new HashMap();
+ replaceMap.put("-k", "****");
+ replaceMap.put("--access-key", "****");
+ replaceMap.put("-a", "****");
+ replaceMap.put("--auth", "****");
+ replaceMap.put("--api-basic-auth", "$1****");
+ replaceMap.put("-x", "$1****$2");
+ replaceMap.put("--proxy", "$1****$2");
+ String replaceForNextElement = null;
+ var hiddenArgs = new ArrayList();
for (String arg : args) {
if (regexpForNextElement != null) {
- hiddenArgs.add(arg.replaceAll(regexpForNextElement, "$1****"));
+ hiddenArgs.add(arg.replaceAll(regexpForNextElement, replaceForNextElement));
regexpForNextElement = null;
} else {
hiddenArgs.add(arg);
regexpForNextElement = map.getOrDefault(arg, null);
+ replaceForNextElement = replaceMap.getOrDefault(arg, null);
}
}
return Arrays.toString(hiddenArgs.toArray());
@@ -296,27 +320,20 @@ public static String getLatestSauceConnectVersion() {
/**
* @param args the initial Sauce Connect command line args
* @param username name of the user which launched Sauce Connect
- * @param apiKey the access key for the Sauce user
- * @param port the port that Sauce Connect should be launched on
+ * @param accessKey the access key for the Sauce user
* @param options command line args specified by the user
* @return String array representing the command line args to be used to launch Sauce Connect
*/
protected String[] generateSauceConnectArgs(
- String[] args, String username, String apiKey, int port, String options) {
+ String[] args, String username, String accessKey, String options) {
String[] result =
- joinArgs(args, "-u", username.trim(), "-k", apiKey.trim(), "-P", String.valueOf(port));
+ joinArgs(args, "run", "--username", username.trim(), "--access-key", accessKey.trim(), "--api-address", ":" + String.valueOf(this.apiPort));
result = addElement(result, options);
return result;
}
protected String[] addExtraInfo(String[] args) {
- String[] result;
- OperatingSystem operatingSystem = OperatingSystem.getOperatingSystem();
- if (operatingSystem == OperatingSystem.WINDOWS) {
- result = joinArgs(args, "--extra-info", "{\\\"runner\\\": \\\"" + runner + "\\\"}");
- } else {
- result = joinArgs(args, "--extra-info", "{\"runner\": \"" + runner + "\"}");
- }
+ String[] result = joinArgs(args, "--metadata", "runner=" + this.runner);
return result;
}
@@ -328,14 +345,15 @@ protected String[] addExtraInfo(String[] args) {
*/
public File extractZipFile(File workingDirectory, OperatingSystem operatingSystem) throws IOException {
String archiveFileName = operatingSystem.getFileName(useLatestSauceConnect);
+ File unzipDir = getUnzipDir(workingDirectory, operatingSystem);
+ unzipDir.mkdirs();
InputStream archiveInputStream = useLatestSauceConnect ?
- new URL("https://saucelabs.com/downloads/" + archiveFileName).openStream() :
+ new URL("https://saucelabs.com/downloads/sauce-connect/" + getCurrentVersion() + "/" + archiveFileName).openStream() :
getClass().getClassLoader().getResourceAsStream(archiveFileName);
- extract(archiveInputStream, workingDirectory.toPath(), operatingSystem.archiveFormat,
+ extract(archiveInputStream, unzipDir.toPath(), operatingSystem.archiveFormat,
operatingSystem.compressionAlgorithm);
- File unzipDir = getUnzipDir(workingDirectory, operatingSystem);
if (cleanUpOnExit) {
unzipDir.deleteOnExit();
}
@@ -358,6 +376,7 @@ private static void extract(InputStream archiveInputStream, Path workingDirector
ArchiveEntry archiveEntry;
while ((archiveEntry = ais.getNextEntry()) != null) {
Path path = workingDirectory.resolve(archiveEntry.getName());
+ path.getParent().toFile().mkdirs();
if (archiveEntry.isDirectory()) {
Files.createDirectories(path);
} else {
@@ -373,9 +392,20 @@ private File getUnzipDir(File workingDirectory, OperatingSystem operatingSystem)
return new File(workingDirectory, operatingSystem.getDirectory(useLatestSauceConnect));
}
- /** {@inheritDoc} */
- protected String getSauceStartedMessage() {
- return SAUCE_CONNECT_4_STARTED;
+ protected boolean isConnected() {
+ HttpClient client = HttpClient.newHttpClient();
+
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create(String.format("http://localhost:%d", this.apiPort)))
+ .GET()
+ .build();
+
+ try {
+ HttpResponse response = client.send(request, HttpResponse.BodyHandlers.discarding());
+ return response.statusCode() == 200;
+ } catch (IOException | InterruptedException e) {
+ return false;
+ }
}
@Override
@@ -408,11 +438,9 @@ private static String getVersion(boolean useLatestSauceConnect) {
*/
@Override
public File getSauceConnectLogFile(String options) {
-
// Has --logfile arg been specified
String logfile = getLogfile(options);
if (logfile != null) {
-
File sauceConnectLogFile = new File(logfile);
if (sauceConnectLogFile.exists()) {
return sauceConnectLogFile;
@@ -421,24 +449,6 @@ public File getSauceConnectLogFile(String options) {
}
}
- // otherwise, try to work out location
- String fileName = "sc.log";
- File logFileDirectory =
- new File(OperatingSystem.getOperatingSystem().getDefaultSauceConnectLogDirectory());
-
- // has --tunnel-name been specified?
- String tunnelName = getTunnelName(options, null);
- if (tunnelName != null) {
- fileName = MessageFormat.format("sc-{0}.log", tunnelName);
- }
- File sauceConnectLogFile = new File(logFileDirectory, fileName);
- if (!sauceConnectLogFile.exists()) {
- // try working directory
- sauceConnectLogFile = new File(getSauceConnectWorkingDirectory(), fileName);
- if (!sauceConnectLogFile.exists()) {
- return null;
- }
- }
- return sauceConnectLogFile;
+ return null;
}
}
diff --git a/src/main/resources/download.sh b/src/main/resources/download.sh
deleted file mode 100755
index 872d179b..00000000
--- a/src/main/resources/download.sh
+++ /dev/null
@@ -1,8 +0,0 @@
-#!/bin/sh
-DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
-URL=${URL:=https://saucelabs.com/versions.json}
-
-rm sc-*.tar.gz sc-*.zip
-curl -s $URL | jq '.["Sauce Connect"] | values[] | select (type=="object") | .download_url' | xargs wget
-CURRENT_SC_VERSION=$(curl -s $URL | jq '.["Sauce Connect"].version')
-perl -pi -e "s/String CURRENT_SC_VERSION = \"(\d+\.?)+\"/String CURRENT_SC_VERSION = $CURRENT_SC_VERSION/" $DIR/../java/com/saucelabs/ci/sauceconnect/SauceConnectFourManager.java
diff --git a/src/main/resources/sauce-connect-5.2.1_darwin.all.zip b/src/main/resources/sauce-connect-5.2.1_darwin.all.zip
new file mode 100644
index 00000000..db4d7ad4
Binary files /dev/null and b/src/main/resources/sauce-connect-5.2.1_darwin.all.zip differ
diff --git a/src/main/resources/sc-4.9.1-osx.zip b/src/main/resources/sauce-connect-5.2.1_linux.aarch64.tar.gz
similarity index 56%
rename from src/main/resources/sc-4.9.1-osx.zip
rename to src/main/resources/sauce-connect-5.2.1_linux.aarch64.tar.gz
index 7f637281..3aca34fb 100644
Binary files a/src/main/resources/sc-4.9.1-osx.zip and b/src/main/resources/sauce-connect-5.2.1_linux.aarch64.tar.gz differ
diff --git a/src/main/resources/sc-4.9.1-linux.tar.gz b/src/main/resources/sauce-connect-5.2.1_linux.x86_64.tar.gz
similarity index 61%
rename from src/main/resources/sc-4.9.1-linux.tar.gz
rename to src/main/resources/sauce-connect-5.2.1_linux.x86_64.tar.gz
index d325404f..e6fe01f5 100644
Binary files a/src/main/resources/sc-4.9.1-linux.tar.gz and b/src/main/resources/sauce-connect-5.2.1_linux.x86_64.tar.gz differ
diff --git a/src/main/resources/sc-4.9.1-win32.zip b/src/main/resources/sauce-connect-5.2.1_windows.aarch64.zip
similarity index 57%
rename from src/main/resources/sc-4.9.1-win32.zip
rename to src/main/resources/sauce-connect-5.2.1_windows.aarch64.zip
index 1b89681f..c190b571 100644
Binary files a/src/main/resources/sc-4.9.1-win32.zip and b/src/main/resources/sauce-connect-5.2.1_windows.aarch64.zip differ
diff --git a/src/main/resources/sc-4.9.1-linux-arm64.tar.gz b/src/main/resources/sauce-connect-5.2.1_windows.x86_64.zip
similarity index 62%
rename from src/main/resources/sc-4.9.1-linux-arm64.tar.gz
rename to src/main/resources/sauce-connect-5.2.1_windows.x86_64.zip
index 594f8138..f776e645 100644
Binary files a/src/main/resources/sc-4.9.1-linux-arm64.tar.gz and b/src/main/resources/sauce-connect-5.2.1_windows.x86_64.zip differ
diff --git a/src/test/java/com/saucelabs/ci/sauceconnect/AbstractSauceTunnelManagerTest.java b/src/test/java/com/saucelabs/ci/sauceconnect/AbstractSauceTunnelManagerTest.java
index 8a7419a3..fc99a26c 100644
--- a/src/test/java/com/saucelabs/ci/sauceconnect/AbstractSauceTunnelManagerTest.java
+++ b/src/test/java/com/saucelabs/ci/sauceconnect/AbstractSauceTunnelManagerTest.java
@@ -22,10 +22,6 @@ void testGetTunnelName() {
"basic",
AbstractSauceTunnelManager.getTunnelName("--tunnel-name basic -c", "default"),
"basic --tunnel-name");
- assertEquals(
- "basic",
- AbstractSauceTunnelManager.getTunnelName("--tunnel-identifier basic -c", "default"),
- "old --tunnel-identifier");
assertEquals(
"third",
AbstractSauceTunnelManager.getTunnelName(
@@ -44,17 +40,4 @@ void testGetLogfile() {
AbstractSauceTunnelManager.getLogfile("-l first --logfile second -c -l third"),
"mix of -l and --logfile still returns the last one");
}
-
- @Test
- void testSystemOutGobbler_ProcessLine() {
- Semaphore semaphore = new Semaphore(1);
- SauceConnectFourManager man = new SauceConnectFourManager(true);
- AbstractSauceTunnelManager.SystemOutGobbler sot = man.makeOutputGobbler(null, null, semaphore);
- sot.processLine("Provisioned tunnel:tunnelId1");
- assertEquals(sot.getTunnelId(), "tunnelId1");
- sot.processLine("Provisioned tunnel: tunnelId2 ");
- assertEquals(sot.getTunnelId(), "tunnelId2");
- sot.processLine("Provisioned tunnel: tunnelId2 ");
- assertEquals(sot.getTunnelId(), "tunnelId2");
- }
}
diff --git a/src/test/java/com/saucelabs/ci/sauceconnect/SauceConnectFourManagerTest.java b/src/test/java/com/saucelabs/ci/sauceconnect/SauceConnectFourManagerTest.java
deleted file mode 100755
index d4a20eb3..00000000
--- a/src/test/java/com/saucelabs/ci/sauceconnect/SauceConnectFourManagerTest.java
+++ /dev/null
@@ -1,247 +0,0 @@
-package com.saucelabs.ci.sauceconnect;
-
-import com.saucelabs.ci.sauceconnect.SauceConnectFourManager.OperatingSystem;
-import com.saucelabs.saucerest.DataCenter;
-import com.saucelabs.saucerest.SauceREST;
-import com.saucelabs.saucerest.api.SauceConnectEndpoint;
-import com.saucelabs.saucerest.model.sauceconnect.TunnelInformation;
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.extension.ExtendWith;
-import org.junit.jupiter.api.io.TempDir;
-import org.junit.jupiter.params.ParameterizedTest;
-import org.junit.jupiter.params.provider.CsvSource;
-import org.junit.jupiter.params.provider.ValueSource;
-import org.mockito.ArgumentCaptor;
-import org.mockito.ArgumentMatcher;
-import org.mockito.Mock;
-import org.mockito.MockedStatic;
-import org.mockito.Spy;
-import org.mockito.junit.jupiter.MockitoExtension;
-
-import java.io.ByteArrayInputStream;
-import java.io.File;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.PrintStream;
-import java.net.http.HttpClient;
-import java.net.http.HttpResponse;
-import java.net.http.HttpResponse.BodyHandler;
-import java.nio.charset.StandardCharsets;
-import java.nio.file.Path;
-import java.util.Locale;
-import java.util.concurrent.TimeUnit;
-import java.util.List;
-
-import static org.hamcrest.MatcherAssert.assertThat;
-import static org.hamcrest.Matchers.allOf;
-import static org.hamcrest.Matchers.greaterThan;
-import static org.hamcrest.Matchers.lessThan;
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertThrows;
-import static org.junit.jupiter.api.Assertions.assertTrue;
-import static org.mockito.ArgumentMatchers.argThat;
-import static org.mockito.Mockito.any;
-import static org.mockito.Mockito.doReturn;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.mockStatic;
-import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.when;
-
-@ExtendWith(MockitoExtension.class)
-class SauceConnectFourManagerTest {
-
- private static final String STARTED_SC_LOG = "/started_sc.log";
- private static final String STARTED_TUNNEL_ID = "a3ccd3985ed04e7ba0fefc7fa401e9c8";
-
- @Mock private Process mockProcess;
- @Mock private SauceREST mockSauceRest;
- @Mock private SauceConnectEndpoint mockSCEndpoint;
- @Spy private final SauceConnectFourManager tunnelManager = new SauceConnectFourManager();
-
- private final PrintStream ps = System.out;
-
- @BeforeEach
- void beforeEach() {
- when(mockSauceRest.getSauceConnectEndpoint()).thenReturn(mockSCEndpoint);
- tunnelManager.setSauceRest(mockSauceRest);
- }
-
- private InputStream getResourceAsStream(String resourceName) {
- return getClass().getResourceAsStream(resourceName);
- }
-
- @ParameterizedTest
- @ValueSource(booleans = {true, false})
- void testOpenConnectionSuccessfully(boolean cleanUpOnExit) throws IOException {
- when(mockSCEndpoint.getTunnelsInformationForAUser()).thenReturn(List.of());
- TunnelInformation readyTunnel = new TunnelInformation();
- readyTunnel.isReady = true;
- when(mockSCEndpoint.getTunnelInformation(STARTED_TUNNEL_ID)).thenReturn(readyTunnel);
- tunnelManager.setCleanUpOnExit(cleanUpOnExit);
- Process process = testOpenConnection(STARTED_SC_LOG);
- assertEquals(mockProcess, process);
- }
-
- @Test
- void openConnectionTest_closes() throws IOException, InterruptedException {
- when(mockSCEndpoint.getTunnelsInformationForAUser()).thenReturn(List.of());
- when(mockProcess.waitFor(30, TimeUnit.SECONDS)).thenReturn(true);
- assertThrows(AbstractSauceTunnelManager.SauceConnectDidNotStartException.class, () -> testOpenConnection(
- "/started_sc_closes.log"));
- verify(mockProcess).destroy();
- }
-
- @Test
- void testOpenConnectionWithExtraSpacesInArgs() throws IOException {
- when(mockSCEndpoint.getTunnelsInformationForAUser()).thenReturn(List.of());
- TunnelInformation notReadyTunnel = new TunnelInformation();
- notReadyTunnel.isReady = false;
- TunnelInformation readyTunnel = new TunnelInformation();
- readyTunnel.isReady = true;
- when(mockSCEndpoint.getTunnelInformation(STARTED_TUNNEL_ID)).thenReturn(notReadyTunnel,
- readyTunnel);
- testOpenConnection(STARTED_SC_LOG, " username-with-spaces-around ");
- }
-
- private Process testOpenConnection(String logFile) throws IOException {
- return testOpenConnection(logFile, "fakeuser");
- }
-
- private Process testOpenConnection(String logFile, String username) throws IOException {
- final String apiKey = "fakeapikey";
- final DataCenter dataCenter = DataCenter.US_WEST;
-
- try (InputStream resourceAsStream = getResourceAsStream(logFile)) {
- when(mockProcess.getErrorStream())
- .thenReturn(new ByteArrayInputStream("".getBytes(StandardCharsets.UTF_8)));
- when(mockProcess.getInputStream()).thenReturn(resourceAsStream);
- doReturn(mockProcess).when(tunnelManager).createProcess(any(String[].class), any(File.class));
- return tunnelManager.openConnection(
- username, apiKey, dataCenter, null, " ", ps, false, "");
- } finally {
- verify(mockSCEndpoint).getTunnelsInformationForAUser();
- ArgumentCaptor argsCaptor = ArgumentCaptor.forClass(String[].class);
- verify(tunnelManager).createProcess(argsCaptor.capture(), any(File.class));
- String[] actualArgs = argsCaptor.getValue();
- assertEquals(9, actualArgs.length);
- assertEquals("-u", actualArgs[1]);
- assertEquals(username.trim(), actualArgs[2]);
- assertEquals("-k", actualArgs[3]);
- assertEquals(apiKey, actualArgs[4]);
- assertEquals("-P", actualArgs[5]);
- assertThat(Integer.parseInt(actualArgs[6]), allOf(greaterThan(0), lessThan(65536)));
- assertEquals("--extra-info", actualArgs[7]);
- OperatingSystem operatingSystem = OperatingSystem.getOperatingSystem();
- if (operatingSystem == OperatingSystem.WINDOWS) {
- assertEquals("{\\\"runner\\\": \\\"jenkins\\\"}", actualArgs[8]);
- } else {
- assertEquals("{\"runner\": \"jenkins\"}", actualArgs[8]);
- }
- }
- }
-
- @Test
- void openConnectionTest_existing_tunnel() throws IOException {
- TunnelInformation started = new TunnelInformation();
- started.tunnelIdentifier = "8949e55fb5e14fd6bf6230b7a609b494";
- started.status = "running";
- started.isReady = true;
-
- when(mockSCEndpoint.getTunnelsInformationForAUser()).thenReturn(List.of(started));
- when(mockSCEndpoint.getTunnelInformation(STARTED_TUNNEL_ID)).thenReturn(started);
-
- Process process = testOpenConnection(STARTED_SC_LOG);
- assertEquals(mockProcess, process);
-
- verify(mockSCEndpoint).getTunnelsInformationForAUser();
- }
-
- @ParameterizedTest
- @CsvSource({
- "true, LINUX",
- "true, WINDOWS",
- "true, OSX",
- "false, LINUX",
- "false, WINDOWS",
- "false, OSX"
- })
- void testExtractZipFile(boolean cleanUpOnExit, OperatingSystem operatingSystem,
- @TempDir Path folder) throws IOException {
- String osName = operatingSystem.name().toLowerCase(Locale.ROOT);
- File destination = folder.resolve("sauceconnect_" + osName).toFile();
-
- SauceConnectFourManager manager = new SauceConnectFourManager();
- manager.setCleanUpOnExit(cleanUpOnExit);
- manager.extractZipFile(destination, operatingSystem);
-
- File expectedBinaryPath = new File(destination, operatingSystem.getDirectory(false));
- File expectedBinaryFile = new File(expectedBinaryPath, operatingSystem.getExecutable());
- assertTrue(expectedBinaryFile.exists(), () -> osName + " binary exists at " + expectedBinaryFile);
- assertTrue(expectedBinaryFile.canExecute(), () -> osName + " binary " + expectedBinaryFile + " is executable");
- }
-
- @Test
- void testSauceConnectSecretsCoveredWithStars() {
- SauceConnectFourManager manager = new SauceConnectFourManager();
- String[] args = {"/sauce/connect/binary/path/"};
- args =
- manager.generateSauceConnectArgs(
- args,
- "username",
- "apiKey-apiKey-apiKey-apiKey-apiKey",
- 1234,
- "--api-key apiKey-apiKey-apiKey-apiKey-apiKey -w user:pwd --proxy-userpwd user:pwd -a host:8080:user:pwd --auth host:8080:user:pwd -p host:8080 --proxy host:8080 -o pwd --other pwd");
- String result = manager.hideSauceConnectCommandlineSecrets(args);
-
- assertEquals(
- "[/sauce/connect/binary/path/, -u, username, -k, ****, -P, 1234, --api-key, ****, -w, user:****, --proxy-userpwd, user:****, -a, host:8080:user:****, --auth, host:8080:user:****, -p, host:8080, --proxy, host:8080, -o, pwd, --other, pwd]",
- result);
- }
-
- @Test
- void testSauceConnectSecretsWithSpecialCharactersCoveredWithStars() {
- SauceConnectFourManager manager = new SauceConnectFourManager();
- String[] args = {"-a", "web-proxy.domain.com:8080:user:pwd"};
- assertEquals(
- "[-a, web-proxy.domain.com:8080:user:****]",
- manager.hideSauceConnectCommandlineSecrets(args));
-
- args = new String[] {"-a", "host:8080:user:passwd%#123"};
- assertEquals("[-a, host:8080:user:****]", manager.hideSauceConnectCommandlineSecrets(args));
-
- args = new String[] {"-a", "host:8080:super-user:passwd"};
- assertEquals(
- "[-a, host:8080:super-user:****]", manager.hideSauceConnectCommandlineSecrets(args));
-
- args = new String[] {"-w", "user:passwd%#123"};
- assertEquals("[-w, user:****]", manager.hideSauceConnectCommandlineSecrets(args));
-
- args = new String[] {"-w", "super-user:passwd"};
- assertEquals("[-w, super-user:****]", manager.hideSauceConnectCommandlineSecrets(args));
- }
-
- @Test
- void shouldInitLatestVersionLazilyAndOnce() throws IOException, InterruptedException {
- try (MockedStatic httpClientStaticMock = mockStatic(HttpClient.class)) {
- HttpClient httpClient = mock();
- HttpResponse httpResponse = mock();
- String version = "4.99.99";
- when(httpResponse.body()).thenReturn("{\"Sauce Connect\": {\"version\": \"" + version + "\"}}");
- when(httpClient.send(any(), argThat((ArgumentMatcher>) argument -> true))).thenReturn(
- httpResponse);
- httpClientStaticMock.when(HttpClient::newHttpClient).thenReturn(httpClient);
-
- SauceConnectFourManager sauceConnectFourManager = new SauceConnectFourManager();
- sauceConnectFourManager.setUseLatestSauceConnect(true);
-
- String currentVersion = sauceConnectFourManager.getCurrentVersion();
- assertEquals(version, currentVersion);
- httpClientStaticMock.verify(HttpClient::newHttpClient);
-
- currentVersion = sauceConnectFourManager.getCurrentVersion();
- assertEquals(version, currentVersion);
- httpClientStaticMock.verifyNoMoreInteractions();
- }
- }
-}
diff --git a/src/test/java/com/saucelabs/ci/sauceconnect/SauceConnectManagerTest.java b/src/test/java/com/saucelabs/ci/sauceconnect/SauceConnectManagerTest.java
new file mode 100755
index 00000000..142333bf
--- /dev/null
+++ b/src/test/java/com/saucelabs/ci/sauceconnect/SauceConnectManagerTest.java
@@ -0,0 +1,415 @@
+package com.saucelabs.ci.sauceconnect;
+
+import com.saucelabs.ci.sauceconnect.AbstractSauceTunnelManager.SCMonitor;
+import com.saucelabs.ci.sauceconnect.SauceConnectManager.OperatingSystem;
+import com.saucelabs.saucerest.DataCenter;
+import com.saucelabs.saucerest.SauceREST;
+import com.saucelabs.saucerest.api.SauceConnectEndpoint;
+import com.saucelabs.saucerest.model.sauceconnect.TunnelInformation;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.ArgumentCaptor;
+import org.mockito.ArgumentMatcher;
+import org.mockito.Mock;
+import org.mockito.MockedStatic;
+import org.mockito.Spy;
+import org.mockito.invocation.InvocationOnMock;
+import org.mockito.stubbing.Answer;
+import org.mockito.junit.jupiter.MockitoExtension;
+import static org.mockito.Mockito.*;
+
+import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.PrintStream;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.net.http.HttpResponse.BodyHandler;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
+import java.util.Locale;
+import java.util.concurrent.Semaphore;
+import java.util.concurrent.TimeUnit;
+import java.util.List;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.allOf;
+import static org.hamcrest.Matchers.greaterThan;
+import static org.hamcrest.Matchers.lessThan;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.argThat;
+import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class SauceConnectManagerTest {
+
+ private static final String STARTED_SC_LOG = "/started_sc.log";
+ private static final String STARTED_TUNNEL_ID = "0bf5b8e2090d4212ad2cc7c241382489";
+
+ @Mock private Process mockProcess;
+ @Mock private SauceREST mockSauceRest;
+ @Mock private SauceConnectEndpoint mockSCEndpoint;
+ @Mock private HttpClient mockHttpClient;
+ @Spy private final SauceConnectManager tunnelManager = new SauceConnectManager();
+
+ private final PrintStream ps = System.out;
+
+ @BeforeEach
+ void beforeEach() {
+ when(mockSauceRest.getSauceConnectEndpoint()).thenReturn(mockSCEndpoint);
+ tunnelManager.setSauceRest(mockSauceRest);
+ }
+
+ private InputStream getResourceAsStream(String resourceName) {
+ return getClass().getResourceAsStream(resourceName);
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {true, false})
+ void testOpenConnectionSuccessfully(boolean cleanUpOnExit) throws IOException {
+ when(mockSCEndpoint.getTunnelsInformationForAUser()).thenReturn(List.of());
+ TunnelInformation readyTunnel = new TunnelInformation();
+ readyTunnel.isReady = true;
+ tunnelManager.setCleanUpOnExit(cleanUpOnExit);
+
+ SCMonitor scMonitor = mock(SCMonitor.class);
+
+ doAnswer(new Answer() {
+ @Override
+ public Void answer(InvocationOnMock invocation) throws Throwable {
+ Semaphore sem = (Semaphore) invocation.getArgument(0);
+ sem.release();
+ return null;
+ }
+ }).when(scMonitor).setSemaphore(any(Semaphore.class));
+
+ tunnelManager.setSCMonitor(scMonitor);
+
+ Process process = testOpenConnection(STARTED_SC_LOG);
+ assertEquals(mockProcess, process);
+ }
+
+ @Test
+ void openConnectionTest_closes() throws IOException, InterruptedException {
+ when(mockSCEndpoint.getTunnelsInformationForAUser()).thenReturn(List.of());
+ when(mockProcess.waitFor(30, TimeUnit.SECONDS)).thenReturn(true);
+
+ SCMonitor scMonitor = mock(SCMonitor.class);
+
+ doAnswer(new Answer() {
+ @Override
+ public Void answer(InvocationOnMock invocation) throws Throwable {
+ Semaphore sem = (Semaphore) invocation.getArgument(0);
+ sem.release();
+ return null;
+ }
+ }).when(scMonitor).setSemaphore(any(Semaphore.class));
+
+ when(scMonitor.isFailed()).thenReturn(true);
+
+ tunnelManager.setSCMonitor(scMonitor);
+ assertThrows(AbstractSauceTunnelManager.SauceConnectDidNotStartException.class, () -> testOpenConnection(
+ "/started_sc_closes.log"));
+ verify(mockProcess).destroy();
+ }
+
+ @Test
+ void testOpenConnectionWithExtraSpacesInArgs() throws IOException {
+ when(mockSCEndpoint.getTunnelsInformationForAUser()).thenReturn(List.of());
+ TunnelInformation notReadyTunnel = new TunnelInformation();
+ notReadyTunnel.isReady = false;
+ TunnelInformation readyTunnel = new TunnelInformation();
+ readyTunnel.isReady = true;
+
+ SCMonitor scMonitor = mock(SCMonitor.class);
+
+ doAnswer(new Answer() {
+ @Override
+ public Void answer(InvocationOnMock invocation) throws Throwable {
+ Semaphore sem = (Semaphore) invocation.getArgument(0);
+ sem.release();
+ return null;
+ }
+ }).when(scMonitor).setSemaphore(any(Semaphore.class));
+
+ tunnelManager.setSCMonitor(scMonitor);
+
+ testOpenConnection(STARTED_SC_LOG, " username-with-spaces-around ");
+ }
+
+ private Process testOpenConnection(String logFile) throws IOException {
+ return testOpenConnection(logFile, "fakeuser");
+ }
+
+ private Process testOpenConnection(String logFile, String username) throws IOException {
+ final String apiKey = "fakeapikey";
+ final DataCenter dataCenter = DataCenter.US_WEST;
+
+ try (InputStream resourceAsStream = getResourceAsStream(logFile)) {
+ doReturn(mockProcess).when(tunnelManager).createProcess(any(String[].class), any(File.class));
+ return tunnelManager.openConnection(
+ username, apiKey, dataCenter, null, " ", ps, false, "");
+ } finally {
+ verify(mockSCEndpoint).getTunnelsInformationForAUser();
+ ArgumentCaptor argsCaptor = ArgumentCaptor.forClass(String[].class);
+ verify(tunnelManager).createProcess(argsCaptor.capture(), any(File.class));
+ String[] actualArgs = argsCaptor.getValue();
+ assertEquals(10, actualArgs.length);
+ assertEquals("run", actualArgs[1]);
+ assertEquals("--username", actualArgs[2]);
+ assertEquals(username.trim(), actualArgs[3]);
+ assertEquals("--access-key", actualArgs[4]);
+ assertEquals(apiKey, actualArgs[5]);
+ assertEquals("--api-address", actualArgs[6]);
+ assertThat(Integer.parseInt(actualArgs[7].substring(1)), allOf(greaterThan(0), lessThan(65536)));
+ assertEquals("--metadata", actualArgs[8]);
+ assertEquals("runner=jenkins", actualArgs[9]);
+ }
+ }
+
+ @Test
+ void openConnectionTest_existing_tunnel() throws IOException {
+ TunnelInformation started = new TunnelInformation();
+ started.tunnelIdentifier = "8949e55fb5e14fd6bf6230b7a609b494";
+ started.status = "running";
+ started.isReady = true;
+
+ when(mockSCEndpoint.getTunnelsInformationForAUser()).thenReturn(List.of(started));
+
+ SCMonitor scMonitor = mock(SCMonitor.class);
+
+ doAnswer(new Answer() {
+ @Override
+ public Void answer(InvocationOnMock invocation) throws Throwable {
+ Semaphore sem = (Semaphore) invocation.getArgument(0);
+ sem.release();
+ return null;
+ }
+ }).when(scMonitor).setSemaphore(any(Semaphore.class));
+
+ tunnelManager.setSCMonitor(scMonitor);
+
+ Process process = testOpenConnection(STARTED_SC_LOG);
+ assertEquals(mockProcess, process);
+
+ verify(mockSCEndpoint).getTunnelsInformationForAUser();
+ }
+
+ @ParameterizedTest
+ @CsvSource({
+ "true, LINUX_AMD64",
+ "true, LINUX_ARM64",
+ "true, WINDOWS_AMD64",
+ "true, WINDOWS_ARM64",
+ "true, OSX",
+ "false, LINUX_AMD64",
+ "false, LINUX_ARM64",
+ "false, WINDOWS_AMD64",
+ "false, WINDOWS_ARM64",
+ "false, OSX"
+ })
+ void testExtractZipFile(boolean cleanUpOnExit, OperatingSystem operatingSystem,
+ @TempDir Path folder) throws IOException {
+ String osName = operatingSystem.name().toLowerCase(Locale.ROOT);
+ File destination = folder.resolve("sauceconnect_" + osName).toFile();
+
+ SauceConnectManager manager = new SauceConnectManager();
+ manager.setCleanUpOnExit(cleanUpOnExit);
+ manager.extractZipFile(destination, operatingSystem);
+
+ File expectedBinaryPath = new File(destination, operatingSystem.getDirectory(false));
+ File expectedBinaryFile = new File(expectedBinaryPath, operatingSystem.getExecutable());
+ assertTrue(expectedBinaryFile.exists(), () -> osName + " binary exists at " + expectedBinaryFile);
+ assertTrue(expectedBinaryFile.canExecute(), () -> osName + " binary " + expectedBinaryFile + " is executable");
+ }
+
+ @Test
+ void testSauceConnectSecretsCoveredWithStars() {
+ SauceConnectManager manager = new SauceConnectManager();
+ String[] args = {"/sauce/connect/binary/path/"};
+ args =
+ manager.generateSauceConnectArgs(
+ args,
+ "username",
+ "apikey",
+ "--access-key apiKey");
+ String result = manager.hideSauceConnectCommandlineSecrets(args);
+
+ assertEquals(
+ "[/sauce/connect/binary/path/, run, --username, username, --access-key, ****, --api-address, :9000, --access-key, ****]",
+ result);
+
+ }
+
+ @Test
+ void testSauceConnectAuthSecretsCoveredWithStars() {
+ SauceConnectManager manager = new SauceConnectManager();
+
+ String[] args = {"/sauce/connect/binary/path/"};
+ args =
+ manager.generateSauceConnectArgs(
+ args,
+ "username",
+ "apikey",
+ "--auth foo:bar@host:8080");
+ String result = manager.hideSauceConnectCommandlineSecrets(args);
+
+ assertEquals(
+ "[/sauce/connect/binary/path/, run, --username, username, --access-key, ****, --api-address, :9000, --auth, ****]",
+ result);
+
+ args = new String[] {"/sauce/connect/binary/path/"};
+ args =
+ manager.generateSauceConnectArgs(
+ args,
+ "username",
+ "apikey",
+ "-a foo:bar@host:8080");
+ result = manager.hideSauceConnectCommandlineSecrets(args);
+
+ assertEquals(
+ "[/sauce/connect/binary/path/, run, --username, username, --access-key, ****, --api-address, :9000, -a, ****]",
+ result);
+
+ args = new String[] {"/sauce/connect/binary/path/"};
+ args =
+ manager.generateSauceConnectArgs(
+ args,
+ "username",
+ "apikey",
+ "-a foo:bar@host:8080 -a user:pwd@host1:1234 --auth root:pass@host2:9999 --auth uucp:pass@host3:8080");
+ result = manager.hideSauceConnectCommandlineSecrets(args);
+
+ assertEquals(
+ "[/sauce/connect/binary/path/, run, --username, username, --access-key, ****, --api-address, :9000, -a, ****, -a, ****, --auth, ****, --auth, ****]",
+ result);
+
+ }
+
+ @Test
+ void testSauceConnectProxySecretsCoveredWithStars() {
+ SauceConnectManager manager = new SauceConnectManager();
+
+ String[] args = {"/sauce/connect/binary/path/"};
+ args =
+ manager.generateSauceConnectArgs(
+ args,
+ "username",
+ "apikey",
+ "--proxy user:pwd@host:8080");
+ String result = manager.hideSauceConnectCommandlineSecrets(args);
+
+ assertEquals(
+ "[/sauce/connect/binary/path/, run, --username, username, --access-key, ****, --api-address, :9000, --proxy, user:****@host:8080]",
+ result);
+
+ args = new String[] {"/sauce/connect/binary/path/"};
+ args =
+ manager.generateSauceConnectArgs(
+ args,
+ "username",
+ "apikey",
+ "--proxy user@host:8080");
+ result = manager.hideSauceConnectCommandlineSecrets(args);
+
+ assertEquals(
+ "[/sauce/connect/binary/path/, run, --username, username, --access-key, ****, --api-address, :9000, --proxy, user@host:8080]",
+ result);
+
+ args = new String[] {"/sauce/connect/binary/path/"};
+ args =
+ manager.generateSauceConnectArgs(
+ args,
+ "username",
+ "apikey",
+ "-x user@host:8080");
+ result = manager.hideSauceConnectCommandlineSecrets(args);
+
+ assertEquals(
+ "[/sauce/connect/binary/path/, run, --username, username, --access-key, ****, --api-address, :9000, -x, user@host:8080]",
+ result);
+
+ args = new String[] {"/sauce/connect/binary/path/"};
+ args =
+ manager.generateSauceConnectArgs(
+ args,
+ "username",
+ "apikey",
+ "--proxy-sauce user@host:8080");
+ result = manager.hideSauceConnectCommandlineSecrets(args);
+
+ assertEquals(
+ "[/sauce/connect/binary/path/, run, --username, username, --access-key, ****, --api-address, :9000, --proxy-sauce, user@host:8080]",
+ result);
+
+ }
+
+ @Test
+ void testSauceConnectAPIBasicAuthSecretsCoveredWithStars() {
+ SauceConnectManager manager = new SauceConnectManager();
+
+ String[] args = {"/sauce/connect/binary/path/"};
+ args =
+ manager.generateSauceConnectArgs(
+ args,
+ "username",
+ "apiKey",
+ "--api-basic-auth user:pwd");
+ String result = manager.hideSauceConnectCommandlineSecrets(args);
+
+ assertEquals(
+ "[/sauce/connect/binary/path/, run, --username, username, --access-key, ****, --api-address, :9000, --api-basic-auth, user:****]",
+ result);
+
+ args = new String[] {"/sauce/connect/binary/path/"};
+ args =
+ manager.generateSauceConnectArgs(
+ args,
+ "username",
+ "apiKey",
+ "--api-basic-auth user");
+ result = manager.hideSauceConnectCommandlineSecrets(args);
+
+ assertEquals(
+ "[/sauce/connect/binary/path/, run, --username, username, --access-key, ****, --api-address, :9000, --api-basic-auth, user]",
+ result);
+ }
+
+ @Test
+ void shouldInitLatestVersionLazilyAndOnce() throws IOException, InterruptedException {
+ try (MockedStatic httpClientStaticMock = mockStatic(HttpClient.class)) {
+ HttpClient httpClient = mock();
+ HttpResponse httpResponse = mock();
+ String version = "5.99.99";
+ when(httpResponse.body()).thenReturn("{\"Sauce Connect\": {\"version\": \"" + version + "\"}}");
+ when(httpClient.send(any(), argThat((ArgumentMatcher>) argument -> true))).thenReturn(
+ httpResponse);
+ httpClientStaticMock.when(HttpClient::newHttpClient).thenReturn(httpClient);
+
+ SauceConnectManager sauceConnectManager = new SauceConnectManager();
+ sauceConnectManager.setUseLatestSauceConnect(true);
+
+ String currentVersion = sauceConnectManager.getCurrentVersion();
+ assertEquals(version, currentVersion);
+ httpClientStaticMock.verify(HttpClient::newHttpClient);
+
+ currentVersion = sauceConnectManager.getCurrentVersion();
+ assertEquals(version, currentVersion);
+ httpClientStaticMock.verifyNoMoreInteractions();
+ }
+ }
+}
diff --git a/src/test/java/com/saucelabs/sod/ExtractFiles.java b/src/test/java/com/saucelabs/sod/ExtractFiles.java
index ee285c19..edac4636 100644
--- a/src/test/java/com/saucelabs/sod/ExtractFiles.java
+++ b/src/test/java/com/saucelabs/sod/ExtractFiles.java
@@ -3,8 +3,8 @@
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.params.provider.Arguments.arguments;
-import com.saucelabs.ci.sauceconnect.SauceConnectFourManager;
-import com.saucelabs.ci.sauceconnect.SauceConnectFourManager.OperatingSystem;
+import com.saucelabs.ci.sauceconnect.SauceConnectManager;
+import com.saucelabs.ci.sauceconnect.SauceConnectManager.OperatingSystem;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
@@ -20,14 +20,15 @@
*/
class ExtractFiles {
- private final SauceConnectFourManager manager = new SauceConnectFourManager();
+ private final SauceConnectManager manager = new SauceConnectManager();
static Stream operatingSystems() {
return Stream.of(
arguments(OperatingSystem.OSX, "sc"),
- arguments(OperatingSystem.LINUX, "sc"),
+ arguments(OperatingSystem.LINUX_AMD64, "sc"),
arguments(OperatingSystem.LINUX_ARM64, "sc"),
- arguments(OperatingSystem.WINDOWS, "sc.exe")
+ arguments(OperatingSystem.WINDOWS_AMD64, "sauce-connect.exe"),
+ arguments(OperatingSystem.WINDOWS_ARM64, "sauce-connect.exe")
);
}
@@ -36,7 +37,7 @@ static Stream operatingSystems() {
void shouldExtractSauceConnectExecutable(OperatingSystem os, String executableFileName, @TempDir Path tempDir)
throws IOException {
File dir = manager.extractZipFile(tempDir.toFile(), os);
- File executableFile = dir.toPath().resolve("bin/" + executableFileName).toFile();
+ File executableFile = dir.toPath().resolve(executableFileName).toFile();
assertTrue(executableFile.exists());
}
}
diff --git a/src/test/resources/started_sc.log b/src/test/resources/started_sc.log
index e2157e80..423afaca 100644
--- a/src/test/resources/started_sc.log
+++ b/src/test/resources/started_sc.log
@@ -1,25 +1,21 @@
-01 Dec 11:02:48 - Sauce Connect 4.3.12, build 1789 45fd5e3
-01 Dec 11:02:48 - *** WARNING: open file limit 256 is too low!
-01 Dec 11:02:48 - *** Sauce Labs recommends setting it to at least 8000.
-01 Dec 11:02:48 - Starting up; pid 91006
-01 Dec 11:02:48 - Command line arguments: /Users/gavinmogan/sc-4.3.12-osx/bin/sc -u halkeye -k ****
-01 Dec 11:02:48 - Using no proxy for connecting to Sauce Labs REST API.
-01 Dec 11:02:48 - Resolving saucelabs.com to 162.222.75.243 took 0 ms.
-01 Dec 11:02:48 - Started scproxy on port 59629.
-01 Dec 11:02:48 - Please wait for 'you may start your tests' to start your tests.
-01 Dec 11:02:48 - Starting secure remote tunnel VM...
-01 Dec 11:02:48 - Checking domain overlap for my domain sauce-connect.proxy, other tunnel domain sauce-connect.proxy
-01 Dec 11:02:48 - Overlapping domain: sauce-connect.proxy, shutting down tunnel 289e7b61380d4861943016759b6e8f23.
-01 Dec 11:02:55 - Secure remote tunnel VM provisioned.
-01 Dec 11:02:55 - Tunnel ID: a3ccd3985ed04e7ba0fefc7fa401e9c8
-01 Dec 11:02:56 - Secure remote tunnel VM is now: booting
-01 Dec 11:02:58 - Secure remote tunnel VM is now: running
-01 Dec 11:02:58 - Using no proxy for connecting to tunnel VM.
-01 Dec 11:02:58 - Resolving tunnel hostname to 162.222.75.22 took 34ms.
-01 Dec 11:02:58 - Starting Selenium listener...
-01 Dec 11:02:58 - Establishing secure TLS connection to tunnel...
-01 Dec 11:02:58 - Selenium listener started on port 4445.
-01 Dec 11:03:06 - Sauce Connect is up, you may start your tests.
-01 Dec 11:12:52 - Cleaning up.
-01 Dec 11:12:52 - Checking domain overlap for my domain sauce-connect.proxy, other tunnel domain sauce-connect.proxy
-01 Dec 11:12:52 - Overlapping domain: sauce-connect.proxy, shutting down tunnel a3ccd3985ed04e7ba0fefc7fa401e9c8.
+2024/09/27 22:19:32.150377 [control] [info] sauce connect proxy version=5.1.0
+2024/09/27 22:19:32.164422 [control] [info] configuration
+access-key=xxxxx
+api-address=:9000
+config-file=us-west.yaml
+log-http=proxy:url
+region=us-west
+tunnel-name=foo
+username=user
+2024/09/27 22:19:32.164655 [proxy] [info] no upstream proxy specified
+2024/09/27 22:19:32.164660 [proxy] [info] localhost proxying mode=deny
+2024/09/27 22:19:32.166448 [control] [info] please wait for 'you may start your tests' to start your tests
+2024/09/27 22:19:32.166461 [control] [info] provisioning sauce connect region=us-west name=foo
+2024/09/27 22:19:38.416501 [control] [info] sauce connect running id=0bf5b8e2090d4212ad2cc7c241382489
+2024/09/27 22:19:38.416631 [tunnel] [info] waiting for sauce connect server to be reachable
+2024/09/27 22:19:38.737149 [api] [info] http server listen address=[::]:9000 protocol=http
+2024/09/27 22:19:38.737528 [tunnel] [info] connecting to address=tunnel-74407d.tunnels.us-west-4-i3er.saucelabs.com:443
+2024/09/27 22:19:39.552134 [tunnel] [info] connecting to address=tunnel-74407d.tunnels.us-west-4-i3er.saucelabs.com:443
+2024/09/27 22:19:39.552201 [tunnel] [info] established connection to sauce connect server active=1/2
+2024/09/27 22:19:39.971981 [control] [info] sauce connect is up, you may start your tests
+2024/09/27 22:19:39.990136 [tunnel] [info] established connection to sauce connect server active=2/2
diff --git a/src/test/resources/started_sc_closes.log b/src/test/resources/started_sc_closes.log
index 64e8327a..e13fff43 100644
--- a/src/test/resources/started_sc_closes.log
+++ b/src/test/resources/started_sc_closes.log
@@ -1,23 +1,21 @@
-16 Nov 18:05:25 - Sauce Connect 4.3.12, build 1789 45fd5e3
-16 Nov 18:05:25 - *** WARNING: open file limit 256 is too low!
-16 Nov 18:05:25 - *** Sauce Labs recommends setting it to at least 8000.
-16 Nov 18:05:25 - Starting up; pid 29128
-16 Nov 18:05:25 - Command line arguments: /Users/gavinmogan/sc-4.3.12-osx/bin/sc -u halkeye -k ****
-16 Nov 18:05:25 - Using no proxy for connecting to Sauce Labs REST API.
-16 Nov 18:05:25 - Resolving saucelabs.com to 162.222.75.243 took 18 ms.
-16 Nov 18:05:25 - Started scproxy on port 65464.
-16 Nov 18:05:25 - Please wait for 'you may start your tests' to start your tests.
-16 Nov 18:05:25 - Starting secure remote tunnel VM...
-16 Nov 18:05:31 - Secure remote tunnel VM provisioned.
-16 Nov 18:05:31 - Tunnel ID: 6987f1030bf2487192d43a9c28f29cb4
-16 Nov 18:05:31 - Secure remote tunnel VM is now: booting
-16 Nov 18:05:43 - Secure remote tunnel VM is now: running
-16 Nov 18:05:43 - Using no proxy for connecting to tunnel VM.
-16 Nov 18:05:43 - Resolving tunnel hostname to 162.222.76.110 took 16ms.
-16 Nov 18:05:43 - Starting Selenium listener...
-16 Nov 18:05:43 - Establishing secure TLS connection to tunnel...
-16 Nov 18:05:43 - Selenium listener started on port 4445.
-16 Nov 18:06:28 - Cleaning up.
-16 Nov 18:06:28 - Checking domain overlap for my domain sauce-connect.proxy, other tunnel domain sauce-connect.proxy
-16 Nov 18:06:28 - Overlapping domain: sauce-connect.proxy, shutting down tunnel 6987f1030bf2487192d43a9c28f29cb4.
-16 Nov 18:06:32 - Goodbye.
+2024/09/27 22:19:32.150377 [control] [info] sauce connect proxy version=5.1.0
+2024/09/27 22:19:32.164422 [control] [info] configuration
+access-key=xxxxx
+api-address=:9000
+config-file=us-west.yaml
+log-http=proxy:url
+region=us-west
+tunnel-name=foo
+username=user
+2024/09/27 22:19:32.164655 [proxy] [info] no upstream proxy specified
+2024/09/27 22:19:32.164660 [proxy] [info] localhost proxying mode=deny
+2024/09/27 22:19:32.166448 [control] [info] please wait for 'you may start your tests' to start your tests
+2024/09/27 22:19:32.166461 [control] [info] provisioning sauce connect region=us-west name=foo
+2024/09/27 22:19:38.416501 [control] [info] sauce connect running id=0bf5b8e2090d4212ad2cc7c241382489
+2024/09/27 22:19:38.416631 [tunnel] [info] waiting for sauce connect server to be reachable
+2024/09/27 22:19:38.737149 [api] [info] http server listen address=[::]:9000 protocol=http
+2024/09/27 22:19:38.737528 [tunnel] [info] connecting to address=tunnel-74407d.tunnels.us-west-4-i3er.saucelabs.com:443
+2024/09/27 22:19:39.552134 [tunnel] [info] connecting to address=tunnel-74407d.tunnels.us-west-4-i3er.saucelabs.com:443
+2024/09/27 22:19:39.552201 [tunnel] [info] established connection to sauce connect server active=1/2
+2024/09/27 22:19:39.990136 [tunnel] [info] established connection to sauce connect server active=2/2
+2024/09/27 22:46:35.320623 [ERROR] fatal error exiting: api: failed to open listener on address :8080: listen tcp :8080: bind: address already in use