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 package com.android.tradefed.config; 17 18 import com.android.annotations.VisibleForTesting; 19 import com.android.tradefed.build.BuildRetrievalError; 20 import com.android.tradefed.config.OptionSetter.OptionFieldsForName; 21 import com.android.tradefed.config.remote.IRemoteFileResolver; 22 import com.android.tradefed.device.ITestDevice; 23 import com.android.tradefed.invoker.logger.CurrentInvocation; 24 import com.android.tradefed.invoker.logger.CurrentInvocation.InvocationInfo; 25 import com.android.tradefed.invoker.logger.InvocationLocal; 26 import com.android.tradefed.log.LogUtil.CLog; 27 import com.android.tradefed.result.error.InfraErrorIdentifier; 28 import com.android.tradefed.util.FileUtil; 29 import com.android.tradefed.util.MultiMap; 30 import com.android.tradefed.util.ZipUtil; 31 import com.android.tradefed.util.ZipUtil2; 32 33 import com.google.common.collect.ImmutableMap; 34 import com.google.common.collect.Maps; 35 36 import java.io.File; 37 import java.io.IOException; 38 import java.lang.reflect.Field; 39 import java.net.URI; 40 import java.net.URISyntaxException; 41 import java.util.ArrayList; 42 import java.util.Collection; 43 import java.util.HashMap; 44 import java.util.HashSet; 45 import java.util.LinkedHashMap; 46 import java.util.List; 47 import java.util.Map; 48 import java.util.Map.Entry; 49 import java.util.ServiceLoader; 50 import java.util.Set; 51 import java.util.function.Supplier; 52 53 import javax.annotation.Nullable; 54 import javax.annotation.concurrent.GuardedBy; 55 import javax.annotation.concurrent.ThreadSafe; 56 57 /** 58 * Class that helps resolving path to remote files. 59 * 60 * <p>For example: gs://bucket/path/file.txt will be resolved by downloading the file from the GCS 61 * bucket. 62 * 63 * <p>New protocols should be added to META_INF/services. 64 */ 65 public class DynamicRemoteFileResolver { 66 67 // Query key for requesting to unzip a downloaded file automatically. 68 public static final String UNZIP_KEY = "unzip"; 69 // Query key for requesting a download to be optional, so if it fails we don't replace it. 70 public static final String OPTIONAL_KEY = "optional"; 71 72 /** 73 * Loads file resolvers using a dedicated {@link ServiceFileResolverLoader} that is scoped to 74 * each invocation. 75 */ 76 // TODO(hzalek): Store a DynamicRemoteFileResolver instance per invocation to avoid locals. 77 private static final FileResolverLoader DEFAULT_FILE_RESOLVER_LOADER = 78 new FileResolverLoader() { 79 private final InvocationLocal<FileResolverLoader> mInvocationLoader = 80 new InvocationLocal<FileResolverLoader>() { 81 @Override 82 protected FileResolverLoader initialValue() { 83 return new ServiceFileResolverLoader(); 84 } 85 }; 86 87 @Override 88 public IRemoteFileResolver load(String scheme, Map<String, String> config) { 89 return mInvocationLoader.get().load(scheme, config); 90 } 91 }; 92 93 private final FileResolverLoader mFileResolverLoader; 94 95 private Map<String, OptionFieldsForName> mOptionMap; 96 // Populated from {@link ICommandOptions#getDynamicDownloadArgs()} 97 private Map<String, String> mExtraArgs = new LinkedHashMap<>(); 98 private ITestDevice mDevice; 99 DynamicRemoteFileResolver()100 public DynamicRemoteFileResolver() { 101 this(DEFAULT_FILE_RESOLVER_LOADER); 102 } 103 104 @VisibleForTesting DynamicRemoteFileResolver(FileResolverLoader loader)105 public DynamicRemoteFileResolver(FileResolverLoader loader) { 106 this.mFileResolverLoader = loader; 107 } 108 109 /** Sets the map of options coming from {@link OptionSetter} */ setOptionMap(Map<String, OptionFieldsForName> optionMap)110 public void setOptionMap(Map<String, OptionFieldsForName> optionMap) { 111 mOptionMap = optionMap; 112 } 113 114 /** Sets the device under tests */ setDevice(ITestDevice device)115 public void setDevice(ITestDevice device) { 116 mDevice = device; 117 } 118 119 /** Add extra args for the query. */ addExtraArgs(Map<String, String> extraArgs)120 public void addExtraArgs(Map<String, String> extraArgs) { 121 mExtraArgs.putAll(extraArgs); 122 } 123 124 /** 125 * Runs through all the {@link File} option type and check if their path should be resolved. 126 * 127 * @return The list of {@link File} that was resolved that way. 128 * @throws BuildRetrievalError 129 */ validateRemoteFilePath()130 public final Set<File> validateRemoteFilePath() throws BuildRetrievalError { 131 Set<File> downloadedFiles = new HashSet<>(); 132 try { 133 Map<Field, Object> fieldSeen = new HashMap<>(); 134 for (Map.Entry<String, OptionFieldsForName> optionPair : mOptionMap.entrySet()) { 135 final OptionFieldsForName optionFields = optionPair.getValue(); 136 for (Map.Entry<Object, Field> fieldEntry : optionFields) { 137 138 final Object obj = fieldEntry.getKey(); 139 final Field field = fieldEntry.getValue(); 140 final Option option = field.getAnnotation(Option.class); 141 if (option == null) { 142 continue; 143 } 144 // At this point, we know this is an option field; make sure it's set 145 field.setAccessible(true); 146 final Object value; 147 try { 148 value = field.get(obj); 149 if (value == null) { 150 continue; 151 } 152 } catch (IllegalAccessException e) { 153 throw new BuildRetrievalError( 154 String.format("internal error: %s", e.getMessage()), 155 InfraErrorIdentifier.ARTIFACT_UNSUPPORTED_PATH); 156 } 157 158 if (fieldSeen.get(field) != null && fieldSeen.get(field).equals(obj)) { 159 continue; 160 } 161 // Keep track of the field set on each object 162 fieldSeen.put(field, obj); 163 164 // The below contains unchecked casts that are mostly safe because we add/remove 165 // items of a type already in the collection; assuming they're not instances of 166 // some subclass of File. This is unlikely since we populate the items during 167 // option injection. The possibility still exists that constructors of 168 // initialized objects add objects that are instances of a File subclass. A 169 // safer approach would be to have a custom type that can be deferenced to 170 // access the resolved target file. This would also have the benefit of not 171 // having to modify any user collections and preserve the ordering. 172 173 if (value instanceof File) { 174 File consideredFile = (File) value; 175 File downloadedFile = resolveRemoteFiles(consideredFile, option); 176 if (downloadedFile != null) { 177 downloadedFiles.add(downloadedFile); 178 // Replace the field value 179 try { 180 field.set(obj, downloadedFile); 181 } catch (IllegalAccessException e) { 182 CLog.e(e); 183 throw new BuildRetrievalError( 184 String.format( 185 "Failed to download %s due to '%s'", 186 consideredFile.getPath(), e.getMessage()), 187 e); 188 } 189 } 190 } else if (value instanceof Collection) { 191 @SuppressWarnings("unchecked") // Mostly-safe, see above comment. 192 Collection<Object> c = (Collection<Object>) value; 193 Collection<Object> copy = new ArrayList<>(c); 194 for (Object o : copy) { 195 if (o instanceof File) { 196 File consideredFile = (File) o; 197 File downloadedFile = resolveRemoteFiles(consideredFile, option); 198 if (downloadedFile != null) { 199 downloadedFiles.add(downloadedFile); 200 // TODO: See if order could be preserved. 201 c.remove(consideredFile); 202 c.add(downloadedFile); 203 } 204 } 205 } 206 } else if (value instanceof Map) { 207 @SuppressWarnings("unchecked") // Mostly-safe, see above comment. 208 Map<Object, Object> m = (Map<Object, Object>) value; 209 Map<Object, Object> copy = new LinkedHashMap<>(m); 210 for (Entry<Object, Object> entry : copy.entrySet()) { 211 Object key = entry.getKey(); 212 Object val = entry.getValue(); 213 214 Object finalKey = key; 215 Object finalVal = val; 216 if (key instanceof File) { 217 key = resolveRemoteFiles((File) key, option); 218 if (key != null) { 219 downloadedFiles.add((File) key); 220 finalKey = key; 221 } 222 } 223 if (val instanceof File) { 224 val = resolveRemoteFiles((File) val, option); 225 if (val != null) { 226 downloadedFiles.add((File) val); 227 finalVal = val; 228 } 229 } 230 231 m.remove(entry.getKey()); 232 m.put(finalKey, finalVal); 233 } 234 } else if (value instanceof MultiMap) { 235 @SuppressWarnings("unchecked") // Mostly-safe, see above comment. 236 MultiMap<Object, Object> m = (MultiMap<Object, Object>) value; 237 synchronized (m) { 238 MultiMap<Object, Object> copy = new MultiMap<>(m); 239 for (Object key : copy.keySet()) { 240 List<Object> mapValues = copy.get(key); 241 242 m.remove(key); 243 Object finalKey = key; 244 if (key instanceof File) { 245 key = resolveRemoteFiles((File) key, option); 246 if (key != null) { 247 downloadedFiles.add((File) key); 248 finalKey = key; 249 } 250 } 251 for (Object mapValue : mapValues) { 252 if (mapValue instanceof File) { 253 File f = resolveRemoteFiles((File) mapValue, option); 254 if (f != null) { 255 downloadedFiles.add(f); 256 mapValue = f; 257 } 258 } 259 m.put(finalKey, mapValue); 260 } 261 } 262 } 263 } 264 } 265 } 266 } catch (RuntimeException | BuildRetrievalError e) { 267 // Clean up the files before throwing 268 for (File f : downloadedFiles) { 269 FileUtil.recursiveDelete(f); 270 } 271 throw e; 272 } 273 return downloadedFiles; 274 } 275 276 /** 277 * Download the files matching given filters in a remote zip file. 278 * 279 * <p>A file inside the remote zip file is only downloaded if its path matches any of the 280 * include filters but not the exclude filters. 281 * 282 * @param destDir the file to place the downloaded contents into. 283 * @param remoteZipFilePath the remote path to the zip file to download, relative to an 284 * implementation specific root. 285 * @param includeFilters a list of regex strings to download matching files. A file's path 286 * matching any filter will be downloaded. 287 * @param excludeFilters a list of regex strings to skip downloading matching files. A file's 288 * path matching any filter will not be downloaded. 289 * @throws BuildRetrievalError if files could not be downloaded. 290 */ resolvePartialDownloadZip( File destDir, String remoteZipFilePath, List<String> includeFilters, List<String> excludeFilters)291 public void resolvePartialDownloadZip( 292 File destDir, 293 String remoteZipFilePath, 294 List<String> includeFilters, 295 List<String> excludeFilters) 296 throws BuildRetrievalError { 297 Map<String, String> queryArgs; 298 String protocol; 299 try { 300 URI uri = new URI(remoteZipFilePath); 301 protocol = uri.getScheme(); 302 queryArgs = parseQuery(uri.getQuery()); 303 } catch (URISyntaxException e) { 304 throw new BuildRetrievalError( 305 String.format( 306 "Failed to parse the remote zip file path: %s", remoteZipFilePath), 307 e); 308 } 309 queryArgs.put("partial_download_dir", destDir.getAbsolutePath()); 310 if (includeFilters != null) { 311 queryArgs.put("include_filters", String.join(";", includeFilters)); 312 } 313 if (excludeFilters != null) { 314 queryArgs.put("exclude_filters", String.join(";", excludeFilters)); 315 } 316 // Downloaded individual files should be saved to destDir, return value is not needed. 317 try { 318 IRemoteFileResolver resolver = getResolver(protocol); 319 resolver.setPrimaryDevice(mDevice); 320 resolver.resolveRemoteFiles(new File(remoteZipFilePath), queryArgs); 321 } catch (BuildRetrievalError e) { 322 if (isOptional(queryArgs)) { 323 CLog.d( 324 "Failed to partially download '%s' but marked optional so skipping: %s", 325 remoteZipFilePath, e.getMessage()); 326 return; 327 } 328 329 throw e; 330 } 331 } 332 getResolver(String protocol)333 private IRemoteFileResolver getResolver(String protocol) throws BuildRetrievalError { 334 try { 335 return mFileResolverLoader.load(protocol, mExtraArgs); 336 } catch (ResolverLoadingException e) { 337 throw new BuildRetrievalError( 338 String.format("Could not load resolver for protocol %s", protocol), e); 339 } 340 } 341 342 @VisibleForTesting getGlobalConfig()343 IGlobalConfiguration getGlobalConfig() { 344 return GlobalConfiguration.getInstance(); 345 } 346 347 /** 348 * Utility that allows to check whether or not a file should be unzip and unzip it if required. 349 */ unzipIfRequired(File downloadedFile, Map<String, String> query)350 public static final File unzipIfRequired(File downloadedFile, Map<String, String> query) 351 throws IOException { 352 String unzipValue = query.get(UNZIP_KEY); 353 if (unzipValue != null && "true".equals(unzipValue.toLowerCase())) { 354 // File was requested to be unzipped. 355 if (ZipUtil.isZipFileValid(downloadedFile, false)) { 356 File extractedDir = 357 FileUtil.createTempDir( 358 FileUtil.getBaseName(downloadedFile.getName()), 359 CurrentInvocation.getInfo(InvocationInfo.WORK_FOLDER)); 360 ZipUtil2.extractZip(downloadedFile, extractedDir); 361 FileUtil.deleteFile(downloadedFile); 362 return extractedDir; 363 } else { 364 CLog.w("%s was requested to be unzipped but is not a valid zip.", downloadedFile); 365 } 366 } 367 // Return the original file untouched 368 return downloadedFile; 369 } 370 resolveRemoteFiles(File consideredFile, Option option)371 private File resolveRemoteFiles(File consideredFile, Option option) throws BuildRetrievalError { 372 File fileToResolve; 373 String path = consideredFile.getPath(); 374 String protocol; 375 Map<String, String> query; 376 try { 377 URI uri = new URI(path); 378 protocol = uri.getScheme(); 379 query = parseQuery(uri.getQuery()); 380 fileToResolve = new File(protocol + ":" + uri.getPath()); 381 } catch (URISyntaxException e) { 382 CLog.e(e); 383 return null; 384 } 385 386 try { 387 IRemoteFileResolver resolver = getResolver(protocol); 388 if (resolver == null) { 389 return null; 390 } 391 392 CLog.d("Considering option '%s' with path: '%s' for download.", option.name(), path); 393 resolver.setPrimaryDevice(mDevice); 394 return resolver.resolveRemoteFiles(fileToResolve, query); 395 } catch (BuildRetrievalError e) { 396 if (isOptional(query)) { 397 CLog.d( 398 "Failed to resolve '%s' but marked optional so skipping: %s", 399 fileToResolve, e.getMessage()); 400 return null; 401 } 402 403 throw e; 404 } 405 } 406 407 /** 408 * Parse a URL query style. Delimited by &, and map values represented by =. Example: 409 * ?key=value&key2=value2 410 */ parseQuery(String query)411 private Map<String, String> parseQuery(String query) { 412 Map<String, String> values = new HashMap<>(); 413 if (query == null) { 414 return values; 415 } 416 for (String maps : query.split("&")) { 417 String[] keyVal = maps.split("="); 418 values.put(keyVal[0], keyVal[1]); 419 } 420 return values; 421 } 422 423 /** Whether or not a link was requested as optional. */ isOptional(Map<String, String> query)424 private boolean isOptional(Map<String, String> query) { 425 String value = query.get(OPTIONAL_KEY); 426 if (value == null) { 427 return false; 428 } 429 return "true".equals(value.toLowerCase()); 430 } 431 432 /** Loads implementations of {@link IRemoteFileResolver}. */ 433 @VisibleForTesting 434 public interface FileResolverLoader { 435 /** 436 * Loads a resolver that can handle the provided scheme. 437 * 438 * @param scheme the URI scheme that the loaded resolver is expected to handle. 439 * @param config a map of all dynamic resolver configuration key-value pairs specified by 440 * the 'dynamic-resolver-args' TF command-line flag. 441 * @throws ResolverLoadingException if the resolver that handles the specified scheme cannot 442 * be loaded and/or initialized. 443 */ 444 @Nullable load(String scheme, Map<String, String> config)445 IRemoteFileResolver load(String scheme, Map<String, String> config); 446 } 447 448 /** Exception thrown if a resolver cannot be loaded or initialized. */ 449 @VisibleForTesting 450 static final class ResolverLoadingException extends RuntimeException { ResolverLoadingException(@ullable String message)451 public ResolverLoadingException(@Nullable String message) { 452 super(message); 453 } 454 ResolverLoadingException(@ullable Throwable cause)455 public ResolverLoadingException(@Nullable Throwable cause) { 456 super(cause); 457 } 458 ResolverLoadingException(@ullable String message, @Nullable Throwable cause)459 public ResolverLoadingException(@Nullable String message, @Nullable Throwable cause) { 460 super(message, cause); 461 } 462 } 463 464 /** 465 * Loads and caches file resolvers using the service loading facility. 466 * 467 * <p>This implementation uses the service loading facility to find and cache available 468 * resolvers on the first call to {@code load}. 469 * 470 * <p>Any {@link Option}-annotated fields defined in loaded resolvers are initialized from the 471 * provided key-value pairs using the standard TF option-setting mechanism. Resolvers can define 472 * options that themselves require resolution as long as it causes no cycles during 473 * initialization. 474 * 475 * <p>Resolvers are loaded eagerly using ServiceLoader but have their options initialized only 476 * when first used. This avoids exceptions due to missing options in resolvers that are 477 * available on the class path but never used to load any files. 478 * 479 * <p>This implementation is thread-safe and ensures that any loaded resolvers are loaded at 480 * most once per instance. 481 */ 482 @ThreadSafe 483 @VisibleForTesting 484 static final class ServiceFileResolverLoader implements FileResolverLoader { 485 // We need the indirection since in production we use the context class loader that is 486 // defined when loading and not the one at construction. 487 private final Supplier<ClassLoader> mClassLoaderSupplier; 488 489 @GuardedBy("this") 490 private @Nullable LoaderState mLoaderState; 491 ServiceFileResolverLoader()492 ServiceFileResolverLoader() { 493 mClassLoaderSupplier = () -> Thread.currentThread().getContextClassLoader(); 494 } 495 ServiceFileResolverLoader(ClassLoader classLoader)496 ServiceFileResolverLoader(ClassLoader classLoader) { 497 mClassLoaderSupplier = () -> classLoader; 498 } 499 500 @Override load(String scheme, Map<String, String> config)501 public synchronized IRemoteFileResolver load(String scheme, Map<String, String> config) { 502 if (mLoaderState != null) { 503 return mLoaderState.getAndInit(scheme); 504 } 505 506 // We use an intermediate map because the ImmutableMap builder throws if we add multiple 507 // entries with the same key. Note that we don't worry about setting any state that 508 // prevents this code from re-executing since failures loading service providers throws 509 // an Error which bubbles all the way to the top. 510 Map<String, IRemoteFileResolver> resolvers = new HashMap<>(); 511 ServiceLoader<IRemoteFileResolver> serviceLoader = 512 ServiceLoader.load(IRemoteFileResolver.class, mClassLoaderSupplier.get()); 513 514 for (IRemoteFileResolver resolver : serviceLoader) { 515 resolvers.putIfAbsent(resolver.getSupportedProtocol(), resolver); 516 } 517 518 mLoaderState = new LoaderState(resolvers, config); 519 return mLoaderState.getAndInit(scheme); 520 } 521 522 /** Stores the state of loaded file resolvers. */ 523 private static final class LoaderState { 524 private final ImmutableMap<String, String> mConfig; 525 private final ImmutableMap<String, ResolverState> mState; 526 LoaderState(Map<String, IRemoteFileResolver> resolvers, Map<String, String> config)527 LoaderState(Map<String, IRemoteFileResolver> resolvers, Map<String, String> config) { 528 this.mState = 529 ImmutableMap.copyOf( 530 Maps.transformValues(resolvers, r -> new ResolverState(r))); 531 this.mConfig = ImmutableMap.copyOf(config); 532 } 533 534 /** Returns an initialized resolver instance for the specified scheme. */ 535 @Nullable getAndInit(String scheme)536 IRemoteFileResolver getAndInit(String scheme) { 537 ResolverState state = mState.get(scheme); 538 if (state == null) { 539 return null; 540 } 541 542 return state.getAndInit(this); 543 } 544 resolve(IRemoteFileResolver resolver)545 void resolve(IRemoteFileResolver resolver) 546 throws ConfigurationException, BuildRetrievalError { 547 // The device isn't set when resolving dynamic options because we don't want to load 548 // device-specific configuration when initializing pseudo-static resolvers that 549 // could out-live a particular device. 550 OptionSetter setter = new OptionSetter(resolver); 551 552 for (Map.Entry<String, String> e : mConfig.entrySet()) { 553 String name = e.getKey(); 554 555 // Note that we don't throw for options that don't exist. 556 if (setter.fieldsForArgNoThrow(name) == null) { 557 // TODO(hzalek): Consider throwing when the option doesn't exist and is 558 // qualified using one of the option source's aliases. 559 // option name uses one of 560 // the option source's aliases 561 continue; 562 } 563 564 if (setter.isMapOption(name)) { 565 throw new ConfigurationException("Map options are not supported: " + name); 566 } 567 568 setter.setOptionValue(name, e.getValue()); 569 } 570 571 Collection<String> missingOptions = setter.getUnsetMandatoryOptions(); 572 if (!missingOptions.isEmpty()) { 573 throw new ConfigurationException( 574 String.format( 575 "Found missing mandatory options %s for resolver %s", 576 missingOptions, resolver.toString())); 577 } 578 579 DynamicRemoteFileResolver dynamicResolver = 580 new DynamicRemoteFileResolver((scheme, unused) -> getAndInit(scheme)); 581 dynamicResolver.addExtraArgs(mConfig); 582 setter.validateRemoteFilePath(dynamicResolver); 583 } 584 585 /** Stores the resolver and its initialization state. */ 586 static final class ResolverState { 587 final IRemoteFileResolver mResolver; 588 589 /** 590 * The initialization state where {@code null} means never initialized, {@code 591 * false} means started, and {@code true} means done. 592 */ 593 @Nullable Boolean mDone; 594 595 /** 596 * The exception thrown when initializing the resolver to ensure that we only do it 597 * once. 598 */ 599 @Nullable ResolverLoadingException mException; 600 ResolverState(IRemoteFileResolver resolver)601 ResolverState(IRemoteFileResolver resolver) { 602 this.mResolver = resolver; 603 } 604 getAndInit(LoaderState context)605 IRemoteFileResolver getAndInit(LoaderState context) { 606 if (Boolean.TRUE.equals(mDone)) { 607 return getOrThrow(); 608 } 609 610 if (Boolean.FALSE.equals(mDone)) { 611 // No need to catch or store the exception since it gets thrown in the 612 // recursive 613 // call to the dynamic resolver as a BuildRetrievalError which we already 614 // catch. 615 throw new ResolverLoadingException( 616 "Cycle detected while initializing resolver options: " 617 + mResolver.toString()); 618 } 619 620 CLog.i("Initializing file resolver options: %s", mResolver); 621 mDone = Boolean.FALSE; 622 623 try { 624 context.resolve(mResolver); 625 } catch (BuildRetrievalError | ConfigurationException e) { 626 mException = 627 new ResolverLoadingException( 628 "Could not initialize resolver options: " 629 + mResolver.toString(), 630 e); 631 throw mException; 632 } finally { 633 mDone = Boolean.TRUE; 634 } 635 636 return mResolver; 637 } 638 getOrThrow()639 private IRemoteFileResolver getOrThrow() { 640 if (mException != null) { 641 throw mException; 642 } 643 return mResolver; 644 } 645 } 646 } 647 } 648 } 649