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