1 /*
2  * Copyright (C) 2018 The Android Open Source Project
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.android.tradefed.util;
18 
19 import com.android.tradefed.build.BuildRetrievalError;
20 import com.android.tradefed.build.IFileDownloader;
21 import com.android.tradefed.invoker.tracing.CloseableTraceScope;
22 import com.android.tradefed.log.LogUtil.CLog;
23 import com.android.tradefed.result.error.InfraErrorIdentifier;
24 
25 import com.google.api.client.googleapis.json.GoogleJsonResponseException;
26 import com.google.api.services.storage.Storage;
27 import com.google.api.services.storage.model.Objects;
28 import com.google.api.services.storage.model.StorageObject;
29 import com.google.common.annotations.VisibleForTesting;
30 import com.google.common.cache.CacheBuilder;
31 import com.google.common.cache.CacheLoader;
32 import com.google.common.cache.LoadingCache;
33 
34 import java.io.ByteArrayInputStream;
35 import java.io.ByteArrayOutputStream;
36 import java.io.File;
37 import java.io.FileOutputStream;
38 import java.io.IOException;
39 import java.io.InputStream;
40 import java.io.OutputStream;
41 import java.math.BigInteger;
42 import java.net.SocketException;
43 import java.net.SocketTimeoutException;
44 import java.nio.file.Paths;
45 import java.util.ArrayList;
46 import java.util.Arrays;
47 import java.util.Collection;
48 import java.util.Collections;
49 import java.util.HashSet;
50 import java.util.List;
51 import java.util.Set;
52 import java.util.concurrent.TimeUnit;
53 import java.util.regex.Matcher;
54 import java.util.regex.Pattern;
55 
56 /** File downloader to download file from google cloud storage (GCS). */
57 public class GCSFileDownloader extends GCSCommon implements IFileDownloader {
58     public static final String GCS_PREFIX = "gs://";
59     public static final String GCS_APPROX_PREFIX = "gs:/";
60 
61     private static final Pattern GCS_PATH_PATTERN = Pattern.compile("gs://([^/]*)/(.*)");
62     private static final String PATH_SEP = "/";
63     private static final Collection<String> SCOPES =
64             Collections.singleton("https://www.googleapis.com/auth/devstorage.read_only");
65     private static final long LIST_BATCH_SIZE = 100;
66 
67     // Allow downloader to create empty files instead of throwing exception.
68     private Boolean mCreateEmptyFile = false;
69 
70     // Cache the freshness
71     private final LoadingCache<String, Boolean> mFreshnessCache;
72 
GCSFileDownloader(File jsonKeyFile)73     public GCSFileDownloader(File jsonKeyFile) {
74         this(false);
75         setJsonKeyFile(jsonKeyFile);
76     }
77 
GCSFileDownloader(Boolean createEmptyFile)78     public GCSFileDownloader(Boolean createEmptyFile) {
79         mCreateEmptyFile = createEmptyFile;
80         mFreshnessCache =
81                 CacheBuilder.newBuilder()
82                         .maximumSize(50)
83                         .expireAfterAccess(60, TimeUnit.MINUTES)
84                         .build(
85                                 new CacheLoader<String, Boolean>() {
86                                     @Override
87                                     public Boolean load(String key) throws BuildRetrievalError {
88                                         return true;
89                                     }
90                                 });
91     }
92 
GCSFileDownloader()93     public GCSFileDownloader() {
94         this(false);
95     }
96 
clearCache()97     protected void clearCache() {
98         mFreshnessCache.invalidateAll();
99     }
100 
getStorage()101     private Storage getStorage() throws IOException {
102         return getStorage(SCOPES);
103     }
104 
105     @VisibleForTesting
getRemoteFileMetaData(String bucketName, String remoteFilename)106     StorageObject getRemoteFileMetaData(String bucketName, String remoteFilename)
107             throws IOException {
108         int i = 0;
109         do {
110             i++;
111             try {
112                 return getStorage().objects().get(bucketName, remoteFilename).execute();
113             } catch (GoogleJsonResponseException e) {
114                 if (e.getStatusCode() == 404) {
115                     return null;
116                 }
117                 throw e;
118             } catch (SocketTimeoutException e) {
119                 // Allow one retry in case of flaky connection.
120                 if (i >= 2) {
121                     throw e;
122                 }
123             }
124         } while (true);
125     }
126 
127     /**
128      * Download file from GCS.
129      *
130      * <p>Right now only support GCS path.
131      *
132      * @param remoteFilePath gs://bucket/file/path format GCS path.
133      * @return local file
134      * @throws BuildRetrievalError
135      */
136     @Override
downloadFile(String remoteFilePath)137     public File downloadFile(String remoteFilePath) throws BuildRetrievalError {
138         File destFile = createTempFile(remoteFilePath, null);
139         try {
140             downloadFile(remoteFilePath, destFile);
141             return destFile;
142         } catch (BuildRetrievalError e) {
143             FileUtil.recursiveDelete(destFile);
144             throw e;
145         }
146     }
147 
148     /**
149      * Download a file from a GCS bucket file.
150      *
151      * @param bucketName GCS bucket name
152      * @param filename the filename
153      * @return {@link InputStream} with the file content.
154      */
downloadFile(String bucketName, String filename)155     public InputStream downloadFile(String bucketName, String filename) throws IOException {
156         InputStream remoteInput = null;
157         ByteArrayOutputStream tmpStream = null;
158         try {
159             remoteInput =
160                     getStorage().objects().get(bucketName, filename).executeMediaAsInputStream();
161             // The input stream from api call can not be reset. Change it to ByteArrayInputStream.
162             tmpStream = new ByteArrayOutputStream();
163             StreamUtil.copyStreams(remoteInput, tmpStream);
164             return new ByteArrayInputStream(tmpStream.toByteArray());
165         } finally {
166             StreamUtil.close(remoteInput);
167             StreamUtil.close(tmpStream);
168         }
169     }
170 
171     @Override
downloadFile(String remotePath, File destFile)172     public void downloadFile(String remotePath, File destFile) throws BuildRetrievalError {
173         String[] pathParts = parseGcsPath(remotePath);
174         downloadFile(pathParts[0], pathParts[1], destFile);
175     }
176 
177     @VisibleForTesting
downloadFile(String bucketName, String remoteFilename, File localFile)178     void downloadFile(String bucketName, String remoteFilename, File localFile)
179             throws BuildRetrievalError {
180         int i = 0;
181         try {
182             do {
183                 i++;
184                 try {
185                     if (!isRemoteFolder(bucketName, remoteFilename)) {
186                         fetchRemoteFile(bucketName, remoteFilename, localFile);
187                         return;
188                     }
189                     remoteFilename = sanitizeDirectoryName(remoteFilename);
190                     recursiveDownloadFolder(bucketName, remoteFilename, localFile);
191                     return;
192                 } catch (SocketException se) {
193                     // Allow one retry in case of flaky connection.
194                     if (i >= 2) {
195                         throw se;
196                     }
197                     CLog.e(
198                             "Error '%s' while downloading gs://%s/%s. retrying.",
199                             se.getMessage(), bucketName, remoteFilename);
200                 }
201             } while (true);
202         } catch (IOException e) {
203             String message =
204                     String.format(
205                             "Failed to download gs://%s/%s due to: %s",
206                             bucketName, remoteFilename, e.getMessage());
207             CLog.e(message);
208             throw new BuildRetrievalError(message, e, InfraErrorIdentifier.GCS_ERROR);
209         }
210     }
211 
isFileFresh(File localFile, StorageObject remoteFile)212     private boolean isFileFresh(File localFile, StorageObject remoteFile) {
213         if (localFile == null && remoteFile == null) {
214             return true;
215         }
216         if (localFile == null || remoteFile == null) {
217             return false;
218         }
219         if (!localFile.exists()) {
220             return false;
221         }
222         return remoteFile.getMd5Hash().equals(FileUtil.calculateBase64Md5(localFile));
223     }
224 
225     @Override
isFresh(File localFile, String remotePath)226     public boolean isFresh(File localFile, String remotePath) throws BuildRetrievalError {
227         String[] pathParts = parseGcsPath(remotePath);
228         String bucketName = pathParts[0];
229         String remoteFilename = pathParts[1];
230 
231         if (localFile != null && localFile.exists()) {
232             Boolean cache = mFreshnessCache.getIfPresent(remotePath);
233             if (cache != null && Boolean.TRUE.equals(cache)) {
234                 return true;
235             }
236         }
237 
238         try (CloseableTraceScope ignored = new CloseableTraceScope("gcs_is_fresh " + remotePath)) {
239             StorageObject remoteFileMeta = getRemoteFileMetaData(bucketName, remoteFilename);
240             if (localFile == null || !localFile.exists()) {
241                 if (!isRemoteFolder(bucketName, remoteFilename) && remoteFileMeta == null) {
242                     // The local doesn't exist and the remote filename is not a folder or a file.
243                     return true;
244                 }
245                 return false;
246             }
247             if (!localFile.isDirectory()) {
248                 return isFileFresh(localFile, remoteFileMeta);
249             }
250             remoteFilename = sanitizeDirectoryName(remoteFilename);
251             boolean fresh = recursiveCheckFolderFreshness(bucketName, remoteFilename, localFile);
252             mFreshnessCache.put(remotePath, fresh);
253             return fresh;
254         } catch (IOException e) {
255             mFreshnessCache.invalidate(remotePath);
256             throw new BuildRetrievalError(e.getMessage(), e, InfraErrorIdentifier.GCS_ERROR);
257         }
258     }
259 
260     /**
261      * Check if remote folder is the same as local folder, recursively. The remoteFolderName must
262      * end with "/".
263      *
264      * @param bucketName is the gcs bucket name.
265      * @param remoteFolderName is the relative path to the bucket.
266      * @param localFolder is the local folder
267      * @return true if local file is the same as remote file, otherwise false.
268      * @throws IOException
269      */
recursiveCheckFolderFreshness( String bucketName, String remoteFolderName, File localFolder)270     private boolean recursiveCheckFolderFreshness(
271             String bucketName, String remoteFolderName, File localFolder) throws IOException {
272         Set<String> subFilenames = new HashSet<>(Arrays.asList(localFolder.list()));
273         List<String> subRemoteFolders = new ArrayList<>();
274         List<StorageObject> subRemoteFiles = new ArrayList<>();
275         listRemoteFilesUnderFolder(bucketName, remoteFolderName, subRemoteFiles, subRemoteFolders);
276         for (StorageObject subRemoteFile : subRemoteFiles) {
277             String subFilename = Paths.get(subRemoteFile.getName()).getFileName().toString();
278             if (!isFileFresh(new File(localFolder, subFilename), subRemoteFile)) {
279                 return false;
280             }
281             subFilenames.remove(subFilename);
282         }
283         for (String subRemoteFolder : subRemoteFolders) {
284             String subFolderName = Paths.get(subRemoteFolder).getFileName().toString();
285             File subFolder = new File(localFolder, subFolderName);
286             if (!subFolder.exists()) {
287                 return false;
288             }
289             if (!subFolder.isDirectory()) {
290                 CLog.w("%s exists as a non-directory.", subFolder);
291                 subFolder = new File(localFolder, subFolderName + "_folder");
292             }
293             if (!recursiveCheckFolderFreshness(bucketName, subRemoteFolder, subFolder)) {
294                 return false;
295             }
296             subFilenames.remove(subFolder.getName());
297         }
298         return subFilenames.isEmpty();
299     }
300 
listRemoteFilesUnderFolder( String bucketName, String folder, List<StorageObject> subFiles, List<String> subFolders)301     void listRemoteFilesUnderFolder(
302             String bucketName, String folder, List<StorageObject> subFiles, List<String> subFolders)
303             throws IOException {
304         String pageToken = null;
305         while (true) {
306             com.google.api.services.storage.Storage.Objects.List listOperation =
307                     getStorage()
308                             .objects()
309                             .list(bucketName)
310                             .setPrefix(folder)
311                             .setDelimiter(PATH_SEP)
312                             .setMaxResults(LIST_BATCH_SIZE);
313             if (pageToken != null) {
314                 listOperation.setPageToken(pageToken);
315             }
316             Objects objects = listOperation.execute();
317             if (objects.getItems() != null && !objects.getItems().isEmpty()) {
318                 for (int i = 0; i < objects.getItems().size(); i++) {
319                     if (objects.getItems().get(i).getName().equals(folder)) {
320                         // If the folder is created from UI, the folder itself
321                         // is a size 0 text file and its name will be
322                         // the folder's name, we should ignore this file.
323                         continue;
324                     }
325                     subFiles.add(objects.getItems().get(i));
326                 }
327             }
328             if (objects.getPrefixes() != null && !objects.getPrefixes().isEmpty()) {
329                 // size 0 sub-folders will also be listed under the prefix.
330                 // So this includes all the sub-folders.
331                 subFolders.addAll(objects.getPrefixes());
332             }
333             pageToken = objects.getNextPageToken();
334             if (pageToken == null) {
335                 return;
336             }
337         }
338     }
339 
parseGcsPath(String remotePath)340     String[] parseGcsPath(String remotePath) throws BuildRetrievalError {
341         if (remotePath.startsWith(GCS_APPROX_PREFIX) && !remotePath.startsWith(GCS_PREFIX)) {
342             // File object remove double // so we have to rebuild it in some cases
343             remotePath = remotePath.replaceAll(GCS_APPROX_PREFIX, GCS_PREFIX);
344         }
345         Matcher m = GCS_PATH_PATTERN.matcher(remotePath);
346         if (!m.find()) {
347             throw new BuildRetrievalError(
348                     String.format("Only GCS path is supported, %s is not supported", remotePath),
349                     InfraErrorIdentifier.ARTIFACT_UNSUPPORTED_PATH);
350         }
351         return new String[] {m.group(1), m.group(2)};
352     }
353 
sanitizeDirectoryName(String name)354     String sanitizeDirectoryName(String name) {
355         /** Folder name should end with "/" */
356         if (!name.endsWith(PATH_SEP)) {
357             name += PATH_SEP;
358         }
359         return name;
360     }
361 
362     /**
363      * Check given filename is a folder or not.
364      *
365      * <p>There 2 types of folders in gcs: 1. Created explicitly from UI. The folder is a size 0
366      * text file (it's an object). 2. When upload a file, all its parent folders will be created,
367      * but these folders doesn't exist (not objects) in gcs. This function work for both cases. But
368      * we should not try to download the size 0 folders.
369      *
370      * @param bucketName is the gcs bucket name.
371      * @param filename is the relative path to the bucket.
372      * @return true if the filename is a folder, otherwise false.
373      */
374     @VisibleForTesting
isRemoteFolder(String bucketName, String filename)375     boolean isRemoteFolder(String bucketName, String filename) throws IOException {
376         filename = sanitizeDirectoryName(filename);
377         Objects objects =
378                 getStorage()
379                         .objects()
380                         .list(bucketName)
381                         .setPrefix(filename)
382                         .setDelimiter(PATH_SEP)
383                         .setMaxResults(1L)
384                         .execute();
385         if (objects.getItems() != null && !objects.getItems().isEmpty()) {
386             // The filename is end with '/', if there are objects use filename as prefix
387             // then filename must be a folder.
388             return true;
389         }
390         if (objects.getPrefixes() != null && !objects.getPrefixes().isEmpty()) {
391             // This will happen when the folder only contains folders but no objects.
392             // objects.getItems() will be empty, but objects.getPrefixes will list
393             // sub-folders.
394             return true;
395         }
396         return false;
397     }
398 
fetchRemoteFile(String bucketName, String remoteFilename, File localFile)399     private void fetchRemoteFile(String bucketName, String remoteFilename, File localFile)
400             throws IOException, BuildRetrievalError {
401         CLog.d("Fetching gs://%s/%s to %s.", bucketName, remoteFilename, localFile.toString());
402         StorageObject meta = getRemoteFileMetaData(bucketName, remoteFilename);
403         if (meta == null || meta.getSize().equals(BigInteger.ZERO)) {
404             if (!mCreateEmptyFile) {
405                 throw new BuildRetrievalError(
406                         String.format(
407                                 "File (not folder) gs://%s/%s doesn't exist or is size 0.",
408                                 bucketName, remoteFilename),
409                         InfraErrorIdentifier.GCS_ERROR);
410             } else {
411                 // Create the empty file.
412                 CLog.d("GCS file is empty: gs://%s/%s", bucketName, remoteFilename);
413                 localFile.createNewFile();
414                 return;
415             }
416         }
417         try (OutputStream writeStream = new FileOutputStream(localFile)) {
418             getStorage()
419                     .objects()
420                     .get(bucketName, remoteFilename)
421                     .executeMediaAndDownloadTo(writeStream);
422         }
423     }
424 
425     /**
426      * Recursively download remote folder to local folder.
427      *
428      * @param bucketName the gcs bucket name
429      * @param remoteFolderName remote folder name, must end with "/"
430      * @param localFolder local folder
431      * @throws IOException
432      * @throws BuildRetrievalError
433      */
recursiveDownloadFolder( String bucketName, String remoteFolderName, File localFolder)434     private void recursiveDownloadFolder(
435             String bucketName, String remoteFolderName, File localFolder)
436             throws IOException, BuildRetrievalError {
437         CLog.d("Downloading folder gs://%s/%s.", bucketName, remoteFolderName);
438         if (!localFolder.exists()) {
439             FileUtil.mkdirsRWX(localFolder);
440         }
441         if (!localFolder.isDirectory()) {
442             String error =
443                     String.format(
444                             "%s is not a folder. (gs://%s/%s)",
445                             localFolder, bucketName, remoteFolderName);
446             CLog.e(error);
447             throw new IOException(error);
448         }
449         Set<String> subFilenames = new HashSet<>(Arrays.asList(localFolder.list()));
450         List<String> subRemoteFolders = new ArrayList<>();
451         List<StorageObject> subRemoteFiles = new ArrayList<>();
452         listRemoteFilesUnderFolder(bucketName, remoteFolderName, subRemoteFiles, subRemoteFolders);
453         for (StorageObject subRemoteFile : subRemoteFiles) {
454             String subFilename = Paths.get(subRemoteFile.getName()).getFileName().toString();
455             fetchRemoteFile(
456                     bucketName, subRemoteFile.getName(), new File(localFolder, subFilename));
457             subFilenames.remove(subFilename);
458         }
459         for (String subRemoteFolder : subRemoteFolders) {
460             String subFolderName = Paths.get(subRemoteFolder).getFileName().toString();
461             File subFolder = new File(localFolder, subFolderName);
462             if (new File(localFolder, subFolderName).exists()
463                     && !new File(localFolder, subFolderName).isDirectory()) {
464                 CLog.w("%s exists as a non-directory.", subFolder);
465                 subFolder = new File(localFolder, subFolderName + "_folder");
466             }
467             recursiveDownloadFolder(bucketName, subRemoteFolder, subFolder);
468             subFilenames.remove(subFolder.getName());
469         }
470         for (String subFilename : subFilenames) {
471             FileUtil.recursiveDelete(new File(localFolder, subFilename));
472         }
473     }
474 
475     @VisibleForTesting
createTempFile(String remoteFilePath, File rootDir)476     File createTempFile(String remoteFilePath, File rootDir) throws BuildRetrievalError {
477         return createTempFileForRemote(remoteFilePath, rootDir);
478     }
479 
480     /**
481      * Creates a unique file on temporary disk to house downloaded file with given path.
482      *
483      * <p>Constructs the file name based on base file name from path
484      *
485      * @param remoteFilePath the remote path to construct the name from
486      */
createTempFileForRemote(String remoteFilePath, File rootDir)487     public static File createTempFileForRemote(String remoteFilePath, File rootDir)
488             throws BuildRetrievalError {
489         try {
490             // create a unique file.
491             File tmpFile = FileUtil.createTempFileForRemote(remoteFilePath, rootDir);
492             // now delete it so name is available
493             tmpFile.delete();
494             return tmpFile;
495         } catch (IOException e) {
496             String msg = String.format("Failed to create tmp file for %s", remoteFilePath);
497             throw new BuildRetrievalError(msg, e);
498         }
499     }
500 }
501