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