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