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