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