1 /* 2 * Copyright 2016 Google LLC 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.google.cloud.testing; 18 19 import com.google.api.core.CurrentMillisClock; 20 import com.google.api.core.InternalApi; 21 import com.google.cloud.ExceptionHandler; 22 import com.google.cloud.RetryHelper; 23 import com.google.cloud.ServiceOptions; 24 import com.google.common.io.CharStreams; 25 import com.google.common.util.concurrent.SettableFuture; 26 import com.google.common.util.concurrent.UncheckedExecutionException; 27 import java.io.BufferedInputStream; 28 import java.io.BufferedOutputStream; 29 import java.io.BufferedReader; 30 import java.io.File; 31 import java.io.FileInputStream; 32 import java.io.FileOutputStream; 33 import java.io.IOException; 34 import java.io.InputStream; 35 import java.io.InputStreamReader; 36 import java.io.OutputStream; 37 import java.math.BigInteger; 38 import java.net.HttpURLConnection; 39 import java.net.ServerSocket; 40 import java.net.URL; 41 import java.net.URLConnection; 42 import java.nio.channels.Channels; 43 import java.nio.channels.ReadableByteChannel; 44 import java.nio.file.Files; 45 import java.nio.file.Path; 46 import java.security.MessageDigest; 47 import java.security.NoSuchAlgorithmException; 48 import java.util.Arrays; 49 import java.util.List; 50 import java.util.Locale; 51 import java.util.concurrent.Callable; 52 import java.util.concurrent.ExecutionException; 53 import java.util.concurrent.TimeUnit; 54 import java.util.concurrent.TimeoutException; 55 import java.util.logging.Level; 56 import java.util.logging.Logger; 57 import java.util.zip.ZipEntry; 58 import java.util.zip.ZipInputStream; 59 import org.threeten.bp.Duration; 60 61 /** Utility class to start and stop a local service which is used by unit testing. */ 62 @InternalApi 63 public abstract class BaseEmulatorHelper<T extends ServiceOptions> { 64 65 private final String emulator; 66 private final int port; 67 private final String projectId; 68 private EmulatorRunner activeRunner; 69 private BlockingProcessStreamReader blockingProcessReader; 70 71 protected static final String PROJECT_ID_PREFIX = "test-project-"; 72 protected static final String DEFAULT_HOST = "localhost"; 73 protected static final int DEFAULT_PORT = 8080; 74 75 @InternalApi("This class should only be extended within google-cloud-java") BaseEmulatorHelper(String emulator, int port, String projectId)76 protected BaseEmulatorHelper(String emulator, int port, String projectId) { 77 this.emulator = emulator; 78 this.port = port > 0 ? port : DEFAULT_PORT; 79 this.projectId = projectId; 80 } 81 82 /** 83 * Returns the emulator runners supported by this emulator. Runners are evaluated in order, the 84 * first available runner is selected and executed 85 */ getEmulatorRunners()86 protected abstract List<EmulatorRunner> getEmulatorRunners(); 87 88 /** Returns a logger. */ getLogger()89 protected abstract Logger getLogger(); 90 91 /** 92 * Starts the local service as a subprocess. Blocks the execution until {@code blockUntilOutput} 93 * is found on stdout. 94 */ startProcess(String blockUntilOutput)95 protected final void startProcess(String blockUntilOutput) 96 throws IOException, InterruptedException { 97 for (EmulatorRunner runner : getEmulatorRunners()) { 98 // Iterate through all emulator runners until find first available runner. 99 if (runner.isAvailable()) { 100 activeRunner = runner; 101 runner.start(); 102 break; 103 } 104 } 105 if (activeRunner != null) { 106 blockingProcessReader = 107 BlockingProcessStreamReader.start( 108 emulator, activeRunner.getProcess().getInputStream(), blockUntilOutput, getLogger()); 109 } else { 110 // No available runner found. 111 throw new IOException("No available emulator runner is found."); 112 } 113 } 114 115 /** 116 * Waits for the local service's subprocess to terminate, and stop any possible thread listening 117 * for its output. 118 */ waitForProcess(Duration timeout)119 protected final int waitForProcess(Duration timeout) 120 throws IOException, InterruptedException, TimeoutException { 121 if (activeRunner != null) { 122 int exitCode = activeRunner.waitFor(timeout); 123 activeRunner = null; 124 return exitCode; 125 } 126 if (blockingProcessReader != null) { 127 blockingProcessReader.join(); 128 blockingProcessReader = null; 129 } 130 return 0; 131 } 132 waitForProcess(final Process process, Duration timeout)133 private static int waitForProcess(final Process process, Duration timeout) 134 throws InterruptedException, TimeoutException { 135 if (process == null) { 136 return 0; 137 } 138 139 final SettableFuture<Integer> exitValue = SettableFuture.create(); 140 141 Thread waiter = 142 new Thread( 143 new Runnable() { 144 @Override 145 public void run() { 146 try { 147 exitValue.set(process.waitFor()); 148 } catch (InterruptedException e) { 149 exitValue.setException(e); 150 } 151 } 152 }); 153 waiter.start(); 154 155 try { 156 return exitValue.get(timeout.toMillis(), TimeUnit.MILLISECONDS); 157 } catch (ExecutionException e) { 158 if (e.getCause() instanceof InterruptedException) { 159 throw (InterruptedException) e.getCause(); 160 } 161 throw new UncheckedExecutionException(e); 162 } finally { 163 waiter.interrupt(); 164 } 165 } 166 167 /** Returns the port to which the local emulator is listening. */ getPort()168 public int getPort() { 169 return port; 170 } 171 172 /** Returns the project ID associated with the local emulator. */ getProjectId()173 public String getProjectId() { 174 return projectId; 175 } 176 177 /** Returns service options to access the local emulator. */ getOptions()178 public abstract T getOptions(); 179 180 /** Starts the local emulator. */ start()181 public abstract void start() throws IOException, InterruptedException; 182 183 /** Stops the local emulator. */ stop(Duration timeout)184 public abstract void stop(Duration timeout) 185 throws IOException, InterruptedException, TimeoutException; 186 187 /** Resets the internal state of the emulator. */ reset()188 public abstract void reset() throws IOException; 189 sendPostRequest(String request)190 protected final String sendPostRequest(String request) throws IOException { 191 URL url = new URL("http", DEFAULT_HOST, this.port, request); 192 HttpURLConnection con = (HttpURLConnection) url.openConnection(); 193 con.setRequestMethod("POST"); 194 con.setDoOutput(true); 195 OutputStream out = con.getOutputStream(); 196 out.write("".getBytes()); 197 out.flush(); 198 199 InputStream in = con.getInputStream(); 200 String response = CharStreams.toString(new InputStreamReader(con.getInputStream())); 201 in.close(); 202 return response; 203 } 204 findAvailablePort(int defaultPort)205 protected static int findAvailablePort(int defaultPort) { 206 try (ServerSocket tempSocket = new ServerSocket(0)) { 207 return tempSocket.getLocalPort(); 208 } catch (IOException e) { 209 return defaultPort; 210 } 211 } 212 isWindows()213 protected static boolean isWindows() { 214 return System.getProperty("os.name").toLowerCase(Locale.ENGLISH).contains("windows"); 215 } 216 217 /** Utility interface to start and run an emulator. */ 218 protected interface EmulatorRunner { 219 220 /** 221 * Returns {@code true} if the emulator associated to this runner is available and can be 222 * started. 223 */ isAvailable()224 boolean isAvailable(); 225 226 /** Starts the emulator associated to this runner. */ start()227 void start() throws IOException; 228 229 /** Wait for the emulator associated to this runner to terminate, returning the exit status. */ waitFor(Duration timeout)230 int waitFor(Duration timeout) throws InterruptedException, TimeoutException; 231 232 /** Returns the process associated to the emulator, if any. */ getProcess()233 Process getProcess(); 234 } 235 236 /** Utility class to start and run an emulator from the Google Cloud SDK. */ 237 protected static class GcloudEmulatorRunner implements EmulatorRunner { 238 239 private final List<String> commandText; 240 private final String versionPrefix; 241 private final Version minVersion; 242 private Process process; 243 private static final Logger log = Logger.getLogger(GcloudEmulatorRunner.class.getName()); 244 GcloudEmulatorRunner(List<String> commandText, String versionPrefix, String minVersion)245 public GcloudEmulatorRunner(List<String> commandText, String versionPrefix, String minVersion) { 246 this.commandText = commandText; 247 this.versionPrefix = versionPrefix; 248 this.minVersion = Version.fromString(minVersion); 249 } 250 251 @Override isAvailable()252 public boolean isAvailable() { 253 try { 254 return isGcloudInstalled() && isEmulatorUpToDate() && !commandText.isEmpty(); 255 } catch (IOException | InterruptedException e) { 256 e.printStackTrace(System.err); 257 } 258 return false; 259 } 260 261 @Override start()262 public void start() throws IOException { 263 log.fine("Starting emulator via Google Cloud SDK"); 264 process = CommandWrapper.create().setCommand(commandText).setRedirectErrorStream().start(); 265 } 266 267 @Override waitFor(Duration timeout)268 public int waitFor(Duration timeout) throws InterruptedException, TimeoutException { 269 return waitForProcess(process, timeout); 270 } 271 272 @Override getProcess()273 public Process getProcess() { 274 return process; 275 } 276 isGcloudInstalled()277 private boolean isGcloudInstalled() { 278 String path = System.getenv("PATH"); 279 return path != null && path.contains("google-cloud-sdk"); 280 } 281 isEmulatorUpToDate()282 private boolean isEmulatorUpToDate() throws IOException, InterruptedException { 283 Version currentVersion = getInstalledEmulatorVersion(versionPrefix); 284 return currentVersion != null && currentVersion.compareTo(minVersion) >= 0; 285 } 286 getInstalledEmulatorVersion(String versionPrefix)287 private Version getInstalledEmulatorVersion(String versionPrefix) 288 throws IOException, InterruptedException { 289 Process process = 290 CommandWrapper.create() 291 .setCommand(Arrays.asList("gcloud", "version")) 292 // gcloud redirects all output to stderr while emulators' executables use either 293 // stdout or stderr with no apparent convention. To be able to properly intercept and 294 // block waiting for emulators to be ready we redirect everything to stdout 295 .setRedirectErrorStream() 296 .start(); 297 process.waitFor(); 298 try (BufferedReader reader = 299 new BufferedReader(new InputStreamReader(process.getInputStream()))) { 300 for (String line = reader.readLine(); line != null; line = reader.readLine()) { 301 if (line.startsWith(versionPrefix)) { 302 String[] lineComponents = line.split(" "); 303 if (lineComponents.length > 1) { 304 return Version.fromString(lineComponents[1]); 305 } 306 } 307 } 308 return null; 309 } 310 } 311 } 312 313 /** Utility class to start and run an emulator from a download URL. */ 314 protected static class DownloadableEmulatorRunner implements EmulatorRunner { 315 316 private final List<String> commandText; 317 private final String md5CheckSum; 318 private final URL downloadUrl; 319 private final String fileName; 320 private String accessToken; 321 private Process process; 322 private static final Logger log = Logger.getLogger(DownloadableEmulatorRunner.class.getName()); 323 DownloadableEmulatorRunner( List<String> commandText, URL downloadUrl, String md5CheckSum)324 public DownloadableEmulatorRunner( 325 List<String> commandText, URL downloadUrl, String md5CheckSum) { 326 this.commandText = commandText; 327 this.md5CheckSum = md5CheckSum; 328 this.downloadUrl = downloadUrl; 329 String[] splitUrl = downloadUrl.toString().split("/"); 330 this.fileName = splitUrl[splitUrl.length - 1]; 331 } 332 DownloadableEmulatorRunner( List<String> commandText, URL downloadUrl, String md5CheckSum, String accessToken)333 public DownloadableEmulatorRunner( 334 List<String> commandText, URL downloadUrl, String md5CheckSum, String accessToken) { 335 this(commandText, downloadUrl, md5CheckSum); 336 this.accessToken = accessToken; 337 } 338 339 @Override isAvailable()340 public boolean isAvailable() { 341 try { 342 downloadZipFile(); 343 return true; 344 } catch (IOException e) { 345 return false; 346 } 347 } 348 349 @Override start()350 public void start() throws IOException { 351 ExceptionHandler retryOnAnythingExceptionHandler = 352 ExceptionHandler.newBuilder().retryOn(Exception.class).build(); 353 354 Path emulatorPath = 355 RetryHelper.runWithRetries( 356 new Callable<Path>() { 357 @Override 358 public Path call() throws IOException { 359 return downloadEmulator(); 360 } 361 }, 362 ServiceOptions.getDefaultRetrySettings(), 363 retryOnAnythingExceptionHandler, 364 CurrentMillisClock.getDefaultClock()); 365 process = 366 CommandWrapper.create() 367 .setCommand(commandText) 368 .setDirectory(emulatorPath) 369 // gcloud redirects all output to stderr while emulators' executables use either 370 // stdout 371 // or stderr with no apparent convention. To be able to properly intercept and block 372 // waiting for emulators to be ready we redirect everything to stdout 373 .setRedirectErrorStream() 374 .start(); 375 } 376 377 @Override waitFor(Duration timeout)378 public int waitFor(Duration timeout) throws InterruptedException, TimeoutException { 379 return waitForProcess(process, timeout); 380 } 381 382 @Override getProcess()383 public Process getProcess() { 384 return process; 385 } 386 downloadEmulator()387 private Path downloadEmulator() throws IOException { 388 // Retrieve the file name from the download link 389 String[] splittedUrl = downloadUrl.toString().split("/"); 390 String fileName = splittedUrl[splittedUrl.length - 1]; 391 392 // Each run is associated with its own folder that is deleted once test completes. 393 Path emulatorPath = Files.createTempDirectory(fileName.split("\\.")[0]); 394 File emulatorFolder = emulatorPath.toFile(); 395 emulatorFolder.deleteOnExit(); 396 397 File zipFile = downloadZipFile(); 398 // Unzip the emulator 399 try (ZipInputStream zipIn = new ZipInputStream(new FileInputStream(zipFile))) { 400 if (log.isLoggable(Level.FINE)) { 401 log.fine("Unzipping emulator"); 402 } 403 ZipEntry entry = zipIn.getNextEntry(); 404 while (entry != null) { 405 File filePath = new File(emulatorFolder, entry.getName()); 406 String canonicalEmulatorFolderPath = emulatorFolder.getCanonicalPath(); 407 String canonicalFilePath = filePath.getCanonicalPath(); 408 if (!canonicalFilePath.startsWith(canonicalEmulatorFolderPath + File.separator)) { 409 throw new IllegalStateException( 410 "Entry is outside of the target dir: " + entry.getName()); 411 } 412 if (!entry.isDirectory()) { 413 extractFile(zipIn, filePath); 414 } else { 415 filePath.mkdir(); 416 } 417 zipIn.closeEntry(); 418 entry = zipIn.getNextEntry(); 419 } 420 } 421 return emulatorPath; 422 } 423 downloadZipFile()424 private File downloadZipFile() throws IOException { 425 // Check if we already have a local copy of the emulator and download it if not. 426 File zipFile = new File(System.getProperty("java.io.tmpdir"), fileName); 427 if (!zipFile.exists() || (md5CheckSum != null && !md5CheckSum.equals(md5(zipFile)))) { 428 if (log.isLoggable(Level.FINE)) { 429 log.fine("Fetching emulator"); 430 } 431 URLConnection urlConnection = downloadUrl.openConnection(); 432 if (accessToken != null) { 433 urlConnection.setRequestProperty("Authorization", "Bearer " + accessToken); 434 } 435 ReadableByteChannel rbc = Channels.newChannel(urlConnection.getInputStream()); 436 try (FileOutputStream fos = new FileOutputStream(zipFile)) { 437 fos.getChannel().transferFrom(rbc, 0, Long.MAX_VALUE); 438 } 439 } else { 440 if (log.isLoggable(Level.FINE)) { 441 log.fine("Using cached emulator"); 442 } 443 } 444 return zipFile; 445 } 446 extractFile(ZipInputStream zipIn, File filePath)447 private void extractFile(ZipInputStream zipIn, File filePath) throws IOException { 448 try (BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(filePath))) { 449 byte[] bytesIn = new byte[1024]; 450 int read; 451 while ((read = zipIn.read(bytesIn)) != -1) { 452 bos.write(bytesIn, 0, read); 453 } 454 } 455 } 456 md5(File zipFile)457 private static String md5(File zipFile) throws IOException { 458 try { 459 MessageDigest md5 = MessageDigest.getInstance("MD5"); 460 try (InputStream is = new BufferedInputStream(new FileInputStream(zipFile))) { 461 byte[] bytes = new byte[4 * 1024 * 1024]; 462 int len; 463 while ((len = is.read(bytes)) >= 0) { 464 md5.update(bytes, 0, len); 465 } 466 } 467 return String.format("%032x", new BigInteger(1, md5.digest())); 468 } catch (NoSuchAlgorithmException e) { 469 throw new IOException(e); 470 } 471 } 472 } 473 } 474