• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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