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.log.LogUtil.CLog; 22 import com.android.tradefed.result.error.InfraErrorIdentifier; 23 24 import com.google.api.client.googleapis.json.GoogleJsonResponseException; 25 import com.google.api.services.storage.Storage; 26 import com.google.api.services.storage.model.Objects; 27 import com.google.api.services.storage.model.StorageObject; 28 import com.google.common.annotations.VisibleForTesting; 29 30 import java.io.ByteArrayInputStream; 31 import java.io.ByteArrayOutputStream; 32 import java.io.File; 33 import java.io.FileOutputStream; 34 import java.io.IOException; 35 import java.io.InputStream; 36 import java.io.OutputStream; 37 import java.net.SocketException; 38 import java.nio.file.Paths; 39 import java.util.ArrayList; 40 import java.util.Arrays; 41 import java.util.Collection; 42 import java.util.Collections; 43 import java.util.HashSet; 44 import java.util.List; 45 import java.util.Set; 46 import java.util.regex.Matcher; 47 import java.util.regex.Pattern; 48 49 /** File downloader to download file from google cloud storage (GCS). */ 50 public class GCSFileDownloader extends GCSCommon implements IFileDownloader { 51 public static final String GCS_PREFIX = "gs://"; 52 public static final String GCS_APPROX_PREFIX = "gs:/"; 53 54 private static final Pattern GCS_PATH_PATTERN = Pattern.compile("gs://([^/]*)/(.*)"); 55 private static final String PATH_SEP = "/"; 56 private static final Collection<String> SCOPES = 57 Collections.singleton("https://www.googleapis.com/auth/devstorage.read_only"); 58 private static final long LIST_BATCH_SIZE = 100; 59 GCSFileDownloader(File jsonKeyFile)60 public GCSFileDownloader(File jsonKeyFile) { 61 super(jsonKeyFile); 62 } 63 GCSFileDownloader()64 public GCSFileDownloader() {} 65 66 /** 67 * Download a file from a GCS bucket file. 68 * 69 * @param bucketName GCS bucket name 70 * @param filename the filename 71 * @return {@link InputStream} with the file content. 72 */ downloadFile(String bucketName, String filename)73 public InputStream downloadFile(String bucketName, String filename) throws IOException { 74 InputStream remoteInput = null; 75 ByteArrayOutputStream tmpStream = null; 76 try { 77 remoteInput = 78 getStorage().objects().get(bucketName, filename).executeMediaAsInputStream(); 79 // The input stream from api call can not be reset. Change it to ByteArrayInputStream. 80 tmpStream = new ByteArrayOutputStream(); 81 StreamUtil.copyStreams(remoteInput, tmpStream); 82 return new ByteArrayInputStream(tmpStream.toByteArray()); 83 } finally { 84 StreamUtil.close(remoteInput); 85 StreamUtil.close(tmpStream); 86 } 87 } 88 getStorage()89 private Storage getStorage() throws IOException { 90 return getStorage(SCOPES); 91 } 92 93 @VisibleForTesting getRemoteFileMetaData(String bucketName, String remoteFilename)94 StorageObject getRemoteFileMetaData(String bucketName, String remoteFilename) 95 throws IOException { 96 try { 97 return getStorage().objects().get(bucketName, remoteFilename).execute(); 98 } catch (GoogleJsonResponseException e) { 99 if (e.getStatusCode() == 404) { 100 return null; 101 } 102 throw e; 103 } 104 } 105 106 /** 107 * Download file from GCS. 108 * 109 * <p>Right now only support GCS path. 110 * 111 * @param remoteFilePath gs://bucket/file/path format GCS path. 112 * @return local file 113 * @throws BuildRetrievalError 114 */ 115 @Override downloadFile(String remoteFilePath)116 public File downloadFile(String remoteFilePath) throws BuildRetrievalError { 117 File destFile = createTempFile(remoteFilePath, null); 118 try { 119 downloadFile(remoteFilePath, destFile); 120 return destFile; 121 } catch (BuildRetrievalError e) { 122 FileUtil.recursiveDelete(destFile); 123 throw e; 124 } 125 } 126 127 @Override downloadFile(String remotePath, File destFile)128 public void downloadFile(String remotePath, File destFile) throws BuildRetrievalError { 129 String[] pathParts = parseGcsPath(remotePath); 130 downloadFile(pathParts[0], pathParts[1], destFile); 131 } 132 isFileFresh(File localFile, StorageObject remoteFile)133 private boolean isFileFresh(File localFile, StorageObject remoteFile) throws IOException { 134 if (localFile == null && remoteFile == null) { 135 return true; 136 } 137 if (localFile == null || remoteFile == null) { 138 return false; 139 } 140 if (!localFile.exists()) { 141 return false; 142 } 143 return remoteFile.getMd5Hash().equals(FileUtil.calculateBase64Md5(localFile)); 144 } 145 146 @Override isFresh(File localFile, String remotePath)147 public boolean isFresh(File localFile, String remotePath) throws BuildRetrievalError { 148 String[] pathParts = parseGcsPath(remotePath); 149 String bucketName = pathParts[0]; 150 String remoteFilename = pathParts[1]; 151 try { 152 StorageObject remoteFileMeta = getRemoteFileMetaData(bucketName, remoteFilename); 153 if (localFile == null || !localFile.exists()) { 154 if (!isRemoteFolder(bucketName, remoteFilename) && remoteFileMeta == null) { 155 // The local doesn't exist and the remote filename is not a folder or a file. 156 return true; 157 } 158 return false; 159 } 160 if (!localFile.isDirectory()) { 161 return isFileFresh(localFile, remoteFileMeta); 162 } 163 remoteFilename = sanitizeDirectoryName(remoteFilename); 164 return recursiveCheckFolderFreshness(bucketName, remoteFilename, localFile); 165 } catch (IOException e) { 166 throw new BuildRetrievalError(e.getMessage(), e); 167 } 168 } 169 170 /** 171 * Check if remote folder is the same as local folder, recursively. The remoteFolderName must 172 * end with "/". 173 * 174 * @param bucketName is the gcs bucket name. 175 * @param remoteFolderName is the relative path to the bucket. 176 * @param localFolder is the local folder 177 * @return true if local file is the same as remote file, otherwise false. 178 * @throws IOException 179 */ recursiveCheckFolderFreshness( String bucketName, String remoteFolderName, File localFolder)180 private boolean recursiveCheckFolderFreshness( 181 String bucketName, String remoteFolderName, File localFolder) throws IOException { 182 Set<String> subFilenames = new HashSet<>(Arrays.asList(localFolder.list())); 183 List<String> subRemoteFolders = new ArrayList<>(); 184 List<StorageObject> subRemoteFiles = new ArrayList<>(); 185 listRemoteFilesUnderFolder(bucketName, remoteFolderName, subRemoteFiles, subRemoteFolders); 186 for (StorageObject subRemoteFile : subRemoteFiles) { 187 String subFilename = Paths.get(subRemoteFile.getName()).getFileName().toString(); 188 if (!isFileFresh(new File(localFolder, subFilename), subRemoteFile)) { 189 return false; 190 } 191 subFilenames.remove(subFilename); 192 } 193 for (String subRemoteFolder : subRemoteFolders) { 194 String subFolderName = Paths.get(subRemoteFolder).getFileName().toString(); 195 File subFolder = new File(localFolder, subFolderName); 196 if (new File(localFolder, subFolderName).exists() 197 && !new File(localFolder, subFolderName).isDirectory()) { 198 CLog.w("%s exists as a non-directory.", subFolder); 199 subFolder = new File(localFolder, subFolderName + "_folder"); 200 } 201 if (!recursiveCheckFolderFreshness(bucketName, subRemoteFolder, subFolder)) { 202 return false; 203 } 204 subFilenames.remove(subFolder.getName()); 205 } 206 return subFilenames.isEmpty(); 207 } 208 listRemoteFilesUnderFolder( String bucketName, String folder, List<StorageObject> subFiles, List<String> subFolders)209 void listRemoteFilesUnderFolder( 210 String bucketName, String folder, List<StorageObject> subFiles, List<String> subFolders) 211 throws IOException { 212 String pageToken = null; 213 while (true) { 214 com.google.api.services.storage.Storage.Objects.List listOperation = 215 getStorage() 216 .objects() 217 .list(bucketName) 218 .setPrefix(folder) 219 .setDelimiter(PATH_SEP) 220 .setMaxResults(LIST_BATCH_SIZE); 221 if (pageToken != null) { 222 listOperation.setPageToken(pageToken); 223 } 224 Objects objects = listOperation.execute(); 225 if (objects.getItems() != null && !objects.getItems().isEmpty()) { 226 subFiles.addAll(objects.getItems()); 227 } 228 if (objects.getPrefixes() != null && !objects.getPrefixes().isEmpty()) { 229 subFolders.addAll(objects.getPrefixes()); 230 } 231 pageToken = objects.getNextPageToken(); 232 if (pageToken == null) { 233 return; 234 } 235 } 236 } 237 parseGcsPath(String remotePath)238 String[] parseGcsPath(String remotePath) throws BuildRetrievalError { 239 if (remotePath.startsWith(GCS_APPROX_PREFIX) && !remotePath.startsWith(GCS_PREFIX)) { 240 // File object remove double // so we have to rebuild it in some cases 241 remotePath = remotePath.replaceAll(GCS_APPROX_PREFIX, GCS_PREFIX); 242 } 243 Matcher m = GCS_PATH_PATTERN.matcher(remotePath); 244 if (!m.find()) { 245 throw new BuildRetrievalError( 246 String.format("Only GCS path is supported, %s is not supported", remotePath), 247 InfraErrorIdentifier.ARTIFACT_UNSUPPORTED_PATH); 248 } 249 return new String[] {m.group(1), m.group(2)}; 250 } 251 sanitizeDirectoryName(String name)252 String sanitizeDirectoryName(String name) { 253 /** Folder name should end with "/" */ 254 if (!name.endsWith(PATH_SEP)) { 255 name += PATH_SEP; 256 } 257 return name; 258 } 259 260 /** check given filename is a folder or not. */ 261 @VisibleForTesting isRemoteFolder(String bucketName, String filename)262 boolean isRemoteFolder(String bucketName, String filename) throws IOException { 263 filename = sanitizeDirectoryName(filename); 264 Objects objects = 265 getStorage() 266 .objects() 267 .list(bucketName) 268 .setPrefix(filename) 269 .setDelimiter(PATH_SEP) 270 .setMaxResults(1l) 271 .execute(); 272 if (objects.getItems() != null && !objects.getItems().isEmpty()) { 273 return true; 274 } 275 if (objects.getPrefixes() != null && !objects.getPrefixes().isEmpty()) { 276 return true; 277 } 278 return false; 279 } 280 281 @VisibleForTesting downloadFile(String bucketName, String remoteFilename, File localFile)282 void downloadFile(String bucketName, String remoteFilename, File localFile) 283 throws BuildRetrievalError { 284 int i = 0; 285 try { 286 do { 287 i++; 288 try { 289 if (!isRemoteFolder(bucketName, remoteFilename)) { 290 fetchRemoteFile(bucketName, remoteFilename, localFile); 291 return; 292 } 293 remoteFilename = sanitizeDirectoryName(remoteFilename); 294 recursiveDownloadFolder(bucketName, remoteFilename, localFile); 295 return; 296 } catch (SocketException se) { 297 // Allow one retry in case of flaky connection. 298 if (i >= 2) { 299 throw se; 300 } 301 CLog.e( 302 "Error '%s' while downloading gs://%s/%s. retrying.", 303 se.getMessage(), bucketName, remoteFilename); 304 } 305 } while (true); 306 } catch (IOException e) { 307 CLog.e("Failed to download gs://%s/%s, clean up.", bucketName, remoteFilename); 308 throw new BuildRetrievalError(e.getMessage(), e); 309 } 310 } 311 fetchRemoteFile(String bucketName, String remoteFilename, File localFile)312 private void fetchRemoteFile(String bucketName, String remoteFilename, File localFile) 313 throws IOException { 314 try (OutputStream writeStream = new FileOutputStream(localFile)) { 315 getStorage() 316 .objects() 317 .get(bucketName, remoteFilename) 318 .executeMediaAndDownloadTo(writeStream); 319 } 320 } 321 322 /** 323 * Recursively download remote folder to local folder. 324 * 325 * @param bucketName the gcs bucket name 326 * @param remoteFolderName remote folder name, must end with "/" 327 * @param localFolder local folder 328 * @throws IOException 329 */ recursiveDownloadFolder( String bucketName, String remoteFolderName, File localFolder)330 private void recursiveDownloadFolder( 331 String bucketName, String remoteFolderName, File localFolder) throws IOException { 332 CLog.d("Downloading folder gs://%s/%s.", bucketName, remoteFolderName); 333 if (!localFolder.exists()) { 334 FileUtil.mkdirsRWX(localFolder); 335 } 336 if (!localFolder.isDirectory()) { 337 String error = 338 String.format( 339 "%s is not a folder. (gs://%s/%s)", 340 localFolder, bucketName, remoteFolderName); 341 CLog.e(error); 342 throw new IOException(error); 343 } 344 Set<String> subFilenames = new HashSet<>(Arrays.asList(localFolder.list())); 345 List<String> subRemoteFolders = new ArrayList<>(); 346 List<StorageObject> subRemoteFiles = new ArrayList<>(); 347 listRemoteFilesUnderFolder(bucketName, remoteFolderName, subRemoteFiles, subRemoteFolders); 348 for (StorageObject subRemoteFile : subRemoteFiles) { 349 String subFilename = Paths.get(subRemoteFile.getName()).getFileName().toString(); 350 fetchRemoteFile( 351 bucketName, subRemoteFile.getName(), new File(localFolder, subFilename)); 352 subFilenames.remove(subFilename); 353 } 354 for (String subRemoteFolder : subRemoteFolders) { 355 String subFolderName = Paths.get(subRemoteFolder).getFileName().toString(); 356 File subFolder = new File(localFolder, subFolderName); 357 if (new File(localFolder, subFolderName).exists() 358 && !new File(localFolder, subFolderName).isDirectory()) { 359 CLog.w("%s exists as a non-directory.", subFolder); 360 subFolder = new File(localFolder, subFolderName + "_folder"); 361 } 362 recursiveDownloadFolder(bucketName, subRemoteFolder, subFolder); 363 subFilenames.remove(subFolder.getName()); 364 } 365 for (String subFilename : subFilenames) { 366 FileUtil.recursiveDelete(new File(localFolder, subFilename)); 367 } 368 } 369 370 /** 371 * Creates a unique file on temporary disk to house downloaded file with given path. 372 * 373 * <p>Constructs the file name based on base file name from path 374 * 375 * @param remoteFilePath the remote path to construct the name from 376 */ 377 @VisibleForTesting createTempFile(String remoteFilePath, File rootDir)378 File createTempFile(String remoteFilePath, File rootDir) throws BuildRetrievalError { 379 try { 380 // create a unique file. 381 File tmpFile = FileUtil.createTempFileForRemote(remoteFilePath, rootDir); 382 // now delete it so name is available 383 tmpFile.delete(); 384 return tmpFile; 385 } catch (IOException e) { 386 String msg = String.format("Failed to create tmp file for %s", remoteFilePath); 387 throw new BuildRetrievalError(msg, e); 388 } 389 } 390 } 391