blob: 5fbb98b1f7fc236aed7e984f39861b811e0b59bf [file] [log] [blame]
Xing Dai77a60172017-12-19 18:07:09 -08001/*
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
17package com.android.tradefed.util;
18
Xing Daiea8bfe42018-06-04 15:47:12 -070019import com.android.tradefed.build.BuildRetrievalError;
20import com.android.tradefed.build.IFileDownloader;
21import com.android.tradefed.log.LogUtil.CLog;
22
Xing Dai842a1762019-01-02 12:13:10 -080023import com.google.auth.Credentials;
24import com.google.auth.oauth2.ServiceAccountCredentials;
25import com.google.auth.oauth2.UserCredentials;
26import com.google.cloud.storage.Blob;
27import com.google.cloud.storage.Bucket;
28import com.google.cloud.storage.Storage;
29import com.google.cloud.storage.Storage.BlobListOption;
30import com.google.cloud.storage.StorageException;
31import com.google.cloud.storage.StorageOptions;
Xing Daiea8bfe42018-06-04 15:47:12 -070032import com.google.common.annotations.VisibleForTesting;
33
Xing Daiea8bfe42018-06-04 15:47:12 -070034import java.io.File;
Xing Dai842a1762019-01-02 12:13:10 -080035import java.io.FileInputStream;
Xing Dai77a60172017-12-19 18:07:09 -080036import java.io.IOException;
37import java.io.InputStream;
Xing Dai842a1762019-01-02 12:13:10 -080038import java.nio.channels.Channels;
Zach Riggle508ce432018-03-13 03:53:10 -050039import java.nio.file.Paths;
Xing Dai842a1762019-01-02 12:13:10 -080040import java.util.Arrays;
xingdai1432ffe2018-08-24 14:44:11 -070041import java.util.HashSet;
42import java.util.Set;
Xing Daiea8bfe42018-06-04 15:47:12 -070043import java.util.regex.Matcher;
44import java.util.regex.Pattern;
Xing Dai77a60172017-12-19 18:07:09 -080045
46/** File downloader to download file from google cloud storage (GCS). */
Xing Daiea8bfe42018-06-04 15:47:12 -070047public class GCSFileDownloader implements IFileDownloader {
Julien Desprezd8aa4182018-12-10 10:59:14 -080048 public static final String GCS_PREFIX = "gs://";
49 public static final String GCS_APPROX_PREFIX = "gs:/";
50
Xing Dai842a1762019-01-02 12:13:10 -080051 private static final Pattern GCS_PATH_PATTERN = Pattern.compile("gs://([^/]*)/(.*)");
xingdai1adb0d02018-08-01 21:20:18 -070052 private static final String PATH_SEP = "/";
Xing Dai77a60172017-12-19 18:07:09 -080053
Xing Dai842a1762019-01-02 12:13:10 -080054 private File mJsonKeyFile = null;
55 private Storage mStorage;
56
57 public GCSFileDownloader(File jsonKeyFile) {
58 mJsonKeyFile = jsonKeyFile;
59 }
60
61 public GCSFileDownloader() {}
62
Xing Dai77a60172017-12-19 18:07:09 -080063 /**
Xing Daiea8bfe42018-06-04 15:47:12 -070064 * Download a file from a GCS bucket file.
Xing Dai77a60172017-12-19 18:07:09 -080065 *
Xing Daiea8bfe42018-06-04 15:47:12 -070066 * @param bucketName GCS bucket name
Xing Dai77a60172017-12-19 18:07:09 -080067 * @param filename the filename
68 * @return {@link InputStream} with the file content.
69 */
70 public InputStream downloadFile(String bucketName, String filename) throws IOException {
Xing Dai842a1762019-01-02 12:13:10 -080071 try {
72 Blob blob = getBucket(bucketName).get(filename);
73 if (blob == null) {
74 throw new IOException(
75 String.format("gs://%s/%s doesn't exist.", bucketName, filename));
76 }
77 return Channels.newInputStream(blob.reader());
78 } catch (StorageException e) {
79 throw new IOException(e);
80 }
81 }
82
83 Storage getStorage() throws IOException {
84 if (mStorage == null) {
85 Credentials credential = null;
86 if (mJsonKeyFile != null && mJsonKeyFile.exists()) {
87 CLog.d("Using json key file %s.", mJsonKeyFile);
88 credential =
89 ServiceAccountCredentials.fromStream(new FileInputStream(mJsonKeyFile));
90 } else {
91 CLog.d("Using local authentication.");
92 try {
93 credential = UserCredentials.getApplicationDefault();
94 } catch (IOException e) {
95 CLog.e(e.getMessage());
96 CLog.e("Try 'gcloud auth application-default login' to login.");
97 throw e;
98 }
99 }
100 mStorage = StorageOptions.newBuilder().setCredentials(credential).build().getService();
101 }
102 return mStorage;
103 }
104
105 Bucket getBucket(String bucketName) throws IOException, StorageException {
106 Bucket bucket = getStorage().get(bucketName);
107 if (bucket == null) {
108 throw new IOException(String.format("Bucket %s doesn't exist.", bucketName));
109 }
110 return bucket;
Xing Dai77a60172017-12-19 18:07:09 -0800111 }
Xing Daiea8bfe42018-06-04 15:47:12 -0700112
113 /**
114 * Download file from GCS.
115 *
116 * <p>Right now only support GCS path.
117 *
118 * @param remoteFilePath gs://bucket/file/path format GCS path.
119 * @return local file
120 * @throws BuildRetrievalError
121 */
122 @Override
123 public File downloadFile(String remoteFilePath) throws BuildRetrievalError {
124 File destFile = createTempFile(remoteFilePath, null);
125 try {
126 downloadFile(remoteFilePath, destFile);
127 return destFile;
128 } catch (BuildRetrievalError e) {
129 FileUtil.recursiveDelete(destFile);
130 throw e;
131 }
132 }
133
134 @Override
135 public void downloadFile(String remotePath, File destFile) throws BuildRetrievalError {
xingdai1432ffe2018-08-24 14:44:11 -0700136 String[] pathParts = parseGcsPath(remotePath);
137 downloadFile(pathParts[0], pathParts[1], destFile);
138 }
139
Xing Dai842a1762019-01-02 12:13:10 -0800140
141 private boolean isFileFresh(File localFile, Blob remoteFile) throws IOException {
142 if (localFile == null && remoteFile == null) {
143 return true;
144 }
145 if (localFile == null || remoteFile == null) {
146 return false;
147 }
148 return remoteFile.getMd5().equals(FileUtil.calculateBase64Md5(localFile));
149 }
150
xingdai1432ffe2018-08-24 14:44:11 -0700151 @Override
152 public boolean isFresh(File localFile, String remotePath) throws BuildRetrievalError {
153 String[] pathParts = parseGcsPath(remotePath);
154 try {
Xing Dai842a1762019-01-02 12:13:10 -0800155 return recursiveCheckFolderFreshness(getBucket(pathParts[0]), pathParts[1], localFile);
156 } catch (IOException | StorageException e) {
xingdai1432ffe2018-08-24 14:44:11 -0700157 throw new BuildRetrievalError(e.getMessage(), e);
158 }
159 }
160
Xing Dai842a1762019-01-02 12:13:10 -0800161 /**
162 * For GCS, if it's a file, we use file content's md5 hash to check if the local file is the
163 * same as the remote file. If it's a folder, we will check all the files in the folder are the
164 * same and all the sub-folders also have the same files.
165 *
166 * @param bucket is the gcs bucket.
167 * @param remoteFilename is the relative path to the bucket.
168 * @param localFile is the local file
169 * @return true if local file is the same as remote file, otherwise false.
170 * @throws IOException
171 * @throws StorageException
172 */
173 private boolean recursiveCheckFolderFreshness(
174 Bucket bucket, String remoteFilename, File localFile)
175 throws IOException, StorageException {
176 if (!localFile.exists()) {
177 return false;
178 }
179 if (localFile.isFile()) {
180 return isFileFresh(localFile, bucket.get(remoteFilename));
181 }
182 // localFile is a folder.
183 Set<String> subFilenames = new HashSet<>(Arrays.asList(localFile.list()));
184 remoteFilename = sanitizeDirectoryName(remoteFilename);
185
186 for (Blob subRemoteFile : listRemoteFilesUnderFolder(bucket, remoteFilename)) {
187 if (subRemoteFile.getName().equals(remoteFilename)) {
188 // Skip the current folder.
189 continue;
190 }
191 String subFilename = Paths.get(subRemoteFile.getName()).getFileName().toString();
192 if (!recursiveCheckFolderFreshness(
193 bucket, subRemoteFile.getName(), new File(localFile, subFilename))) {
194 return false;
195 }
196 subFilenames.remove(subFilename);
197 }
198 return subFilenames.isEmpty();
199 }
200
201 Iterable<Blob> listRemoteFilesUnderFolder(Bucket bucket, String folder) {
202 return bucket.list(
203 BlobListOption.prefix(sanitizeDirectoryName(folder)),
204 BlobListOption.currentDirectory())
205 .iterateAll();
206 }
207
xingdai1432ffe2018-08-24 14:44:11 -0700208 String[] parseGcsPath(String remotePath) throws BuildRetrievalError {
Xing Daif6b78b22018-12-18 13:35:50 -0800209 if (remotePath.startsWith(GCS_APPROX_PREFIX) && !remotePath.startsWith(GCS_PREFIX)) {
210 // File object remove double // so we have to rebuild it in some cases
211 remotePath = remotePath.replaceAll(GCS_APPROX_PREFIX, GCS_PREFIX);
212 }
Xing Daiea8bfe42018-06-04 15:47:12 -0700213 Matcher m = GCS_PATH_PATTERN.matcher(remotePath);
214 if (!m.find()) {
215 throw new BuildRetrievalError(
216 String.format("Only GCS path is supported, %s is not supported", remotePath));
217 }
xingdai1432ffe2018-08-24 14:44:11 -0700218 return new String[] {m.group(1), m.group(2)};
219 }
220
xingdai1432ffe2018-08-24 14:44:11 -0700221 String sanitizeDirectoryName(String name) {
Xing Dai842a1762019-01-02 12:13:10 -0800222 /** Folder name should end with "/" */
xingdai1432ffe2018-08-24 14:44:11 -0700223 if (!name.endsWith(PATH_SEP)) {
224 name += PATH_SEP;
225 }
226 return name;
Xing Daiea8bfe42018-06-04 15:47:12 -0700227 }
228
Xing Dai842a1762019-01-02 12:13:10 -0800229 /** check given filename is a folder or not. */
230 private boolean isFolder(Bucket bucket, String filename) throws StorageException {
231 filename = sanitizeDirectoryName(filename);
232 return bucket.list(BlobListOption.prefix(filename), BlobListOption.currentDirectory())
233 .iterateAll()
234 .iterator()
235 .hasNext();
236 }
237
Xing Daiea8bfe42018-06-04 15:47:12 -0700238 @VisibleForTesting
239 void downloadFile(String bucketName, String filename, File localFile)
240 throws BuildRetrievalError {
Xing Daiea8bfe42018-06-04 15:47:12 -0700241 try {
Xing Dai842a1762019-01-02 12:13:10 -0800242 recursiveDownload(getStorage().get(bucketName), filename, localFile);
243 } catch (IOException | StorageException e) {
244 CLog.e("Failed to download gs://%s/%s, clean up.", bucketName, filename);
Xing Daiea8bfe42018-06-04 15:47:12 -0700245 throw new BuildRetrievalError(e.getMessage(), e);
246 }
247 }
248
Xing Dai842a1762019-01-02 12:13:10 -0800249 private void recursiveDownload(Bucket bucket, String filepath, File localFile)
250 throws StorageException, IOException {
251 CLog.d(
252 "Downloading gs://%s/%s to %s",
253 bucket.getName(), filepath, localFile.getAbsolutePath());
254 if (!isFolder(bucket, filepath)) {
255 Blob blob = bucket.get(filepath);
256 if (blob == null) {
257 throw new IOException(
258 String.format("gs://%s/%s doesn't exist.", bucket.getName(), filepath));
259 }
260 blob.downloadTo(localFile.toPath());
261 return;
262 }
263 // Remote file is a folder.
264 filepath = sanitizeDirectoryName(filepath);
265 if (!localFile.exists()) {
266 FileUtil.mkdirsRWX(localFile);
267 }
268 Set<String> subFilenames = new HashSet<>(Arrays.asList(localFile.list()));
269 for (Blob subRemoteFile : listRemoteFilesUnderFolder(bucket, filepath)) {
270 if (subRemoteFile.getName().equals(filepath)) {
271 // Skip the current folder.
272 continue;
273 }
274 String subFilename = Paths.get(subRemoteFile.getName()).getFileName().toString();
275 recursiveDownload(bucket, subRemoteFile.getName(), new File(localFile, subFilename));
276 subFilenames.remove(subFilename);
277 }
278 for (String subFilename : subFilenames) {
279 FileUtil.recursiveDelete(new File(localFile, subFilename));
280 }
xingdai1adb0d02018-08-01 21:20:18 -0700281 }
282
Xing Daiea8bfe42018-06-04 15:47:12 -0700283 /**
284 * Creates a unique file on temporary disk to house downloaded file with given path.
285 *
286 * <p>Constructs the file name based on base file name from path
287 *
288 * @param remoteFilePath the remote path to construct the name from
289 */
xingdai1adb0d02018-08-01 21:20:18 -0700290 @VisibleForTesting
291 File createTempFile(String remoteFilePath, File rootDir) throws BuildRetrievalError {
Xing Daiea8bfe42018-06-04 15:47:12 -0700292 try {
293 // create a unique file.
294 File tmpFile = FileUtil.createTempFileForRemote(remoteFilePath, rootDir);
295 // now delete it so name is available
296 tmpFile.delete();
297 return tmpFile;
298 } catch (IOException e) {
299 String msg = String.format("Failed to create tmp file for %s", remoteFilePath);
300 throw new BuildRetrievalError(msg, e);
301 }
302 }
Xing Dai77a60172017-12-19 18:07:09 -0800303}