| /* |
| * Copyright (C) 2018 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| package com.android.tradefed.config; |
| |
| import com.android.annotations.VisibleForTesting; |
| import com.android.tradefed.build.BuildRetrievalError; |
| import com.android.tradefed.config.OptionSetter.OptionFieldsForName; |
| import com.android.tradefed.config.remote.ExtendedFile; |
| import com.android.tradefed.config.remote.IRemoteFileResolver; |
| import com.android.tradefed.config.remote.IRemoteFileResolver.RemoteFileResolverArgs; |
| import com.android.tradefed.config.remote.IRemoteFileResolver.ResolvedFile; |
| import com.android.tradefed.device.ITestDevice; |
| import com.android.tradefed.error.HarnessRuntimeException; |
| import com.android.tradefed.error.IHarnessException; |
| import com.android.tradefed.invoker.logger.CurrentInvocation; |
| import com.android.tradefed.invoker.logger.CurrentInvocation.InvocationInfo; |
| import com.android.tradefed.invoker.logger.InvocationLocal; |
| import com.android.tradefed.invoker.tracing.CloseableTraceScope; |
| import com.android.tradefed.log.LogUtil.CLog; |
| import com.android.tradefed.result.error.ErrorIdentifier; |
| import com.android.tradefed.result.error.InfraErrorIdentifier; |
| import com.android.tradefed.util.FileUtil; |
| import com.android.tradefed.util.IDisableable; |
| import com.android.tradefed.util.MultiMap; |
| import com.android.tradefed.util.ZipUtil; |
| import com.android.tradefed.util.ZipUtil2; |
| |
| import com.google.common.collect.ImmutableMap; |
| import com.google.common.collect.Maps; |
| |
| import java.io.File; |
| import java.io.IOException; |
| import java.lang.reflect.Field; |
| import java.net.URI; |
| import java.net.URISyntaxException; |
| import java.util.ArrayList; |
| import java.util.Collection; |
| import java.util.HashMap; |
| import java.util.HashSet; |
| import java.util.LinkedHashMap; |
| import java.util.List; |
| import java.util.Map; |
| import java.util.Map.Entry; |
| import java.util.ServiceLoader; |
| import java.util.Set; |
| import java.util.function.Supplier; |
| |
| import javax.annotation.Nullable; |
| import javax.annotation.concurrent.GuardedBy; |
| import javax.annotation.concurrent.ThreadSafe; |
| |
| /** |
| * Class that helps resolving path to remote files. |
| * |
| * <p>For example: gs://bucket/path/file.txt will be resolved by downloading the file from the GCS |
| * bucket. |
| * |
| * <p>New protocols should be added to META_INF/services. |
| */ |
| public class DynamicRemoteFileResolver { |
| |
| // Query key for requesting to unzip a downloaded file automatically. |
| public static final String UNZIP_KEY = "unzip"; |
| // Query key for requesting a download to be optional, so if it fails we don't replace it. |
| public static final String OPTIONAL_KEY = "optional"; |
| // Query key for the option name being resolved. |
| public static final String OPTION_NAME_KEY = "option_name"; |
| // Query key for the parallel setting |
| public static final String OPTION_PARALLEL_KEY = "parallel"; |
| |
| /** |
| * Loads file resolvers using a dedicated {@link ServiceFileResolverLoader} that is scoped to |
| * each invocation. |
| */ |
| // TODO(hzalek): Store a DynamicRemoteFileResolver instance per invocation to avoid locals. |
| private static final FileResolverLoader DEFAULT_FILE_RESOLVER_LOADER = |
| new FileResolverLoader() { |
| private final InvocationLocal<FileResolverLoader> mInvocationLoader = |
| new InvocationLocal<FileResolverLoader>() { |
| @Override |
| protected FileResolverLoader initialValue() { |
| return new ServiceFileResolverLoader(); |
| } |
| }; |
| |
| @Override |
| public IRemoteFileResolver load(String scheme, Map<String, String> config) { |
| return mInvocationLoader.get().load(scheme, config); |
| } |
| }; |
| |
| private final FileResolverLoader mFileResolverLoader; |
| private final boolean mAllowParallelization; |
| |
| private Map<String, OptionFieldsForName> mOptionMap; |
| // Populated from {@link ICommandOptions#getDynamicDownloadArgs()} |
| private Map<String, String> mExtraArgs = new LinkedHashMap<>(); |
| private ITestDevice mDevice; |
| private List<ExtendedFile> mParallelExtendedFiles = new ArrayList<>(); |
| |
| public DynamicRemoteFileResolver() { |
| this(DEFAULT_FILE_RESOLVER_LOADER); |
| } |
| |
| public DynamicRemoteFileResolver(boolean allowParallel) { |
| this(DEFAULT_FILE_RESOLVER_LOADER, allowParallel); |
| } |
| |
| @VisibleForTesting |
| public DynamicRemoteFileResolver(FileResolverLoader loader) { |
| this(loader, false); |
| } |
| |
| @VisibleForTesting |
| public DynamicRemoteFileResolver(FileResolverLoader loader, boolean allowParallel) { |
| this.mFileResolverLoader = loader; |
| this.mAllowParallelization = allowParallel; |
| } |
| |
| /** Sets the map of options coming from {@link OptionSetter} */ |
| public void setOptionMap(Map<String, OptionFieldsForName> optionMap) { |
| mOptionMap = optionMap; |
| } |
| |
| /** Sets the device under tests */ |
| public void setDevice(ITestDevice device) { |
| mDevice = device; |
| } |
| |
| /** Add extra args for the query. */ |
| public void addExtraArgs(Map<String, String> extraArgs) { |
| mExtraArgs.putAll(extraArgs); |
| } |
| |
| public List<ExtendedFile> getParallelDownloads() { |
| return mParallelExtendedFiles; |
| } |
| |
| /** |
| * Runs through all the {@link File} option type and check if their path should be resolved. |
| * |
| * @return The list of {@link File} that was resolved that way. |
| * @throws BuildRetrievalError |
| */ |
| public final Set<File> validateRemoteFilePath() throws BuildRetrievalError { |
| Set<File> downloadedFiles = new HashSet<>(); |
| try { |
| Map<Field, Object> fieldSeen = new HashMap<>(); |
| for (Map.Entry<String, OptionFieldsForName> optionPair : mOptionMap.entrySet()) { |
| final OptionFieldsForName optionFields = optionPair.getValue(); |
| for (Map.Entry<Object, Field> fieldEntry : optionFields) { |
| |
| final Object obj = fieldEntry.getKey(); |
| if (obj instanceof IDisableable && ((IDisableable) obj).isDisabled()) { |
| continue; |
| } |
| final Field field = fieldEntry.getValue(); |
| final Option option = field.getAnnotation(Option.class); |
| if (option == null) { |
| continue; |
| } |
| // At this point, we know this is an option field; make sure it's set |
| field.setAccessible(true); |
| final Object value; |
| try { |
| value = field.get(obj); |
| if (value == null) { |
| continue; |
| } |
| } catch (IllegalAccessException e) { |
| throw new BuildRetrievalError( |
| String.format("internal error: %s", e.getMessage()), |
| InfraErrorIdentifier.ARTIFACT_UNSUPPORTED_PATH); |
| } |
| |
| if (fieldSeen.get(field) != null && fieldSeen.get(field).equals(obj)) { |
| continue; |
| } |
| // Keep track of the field set on each object |
| fieldSeen.put(field, obj); |
| |
| // The below contains unchecked casts that are mostly safe because we add/remove |
| // items of a type already in the collection; assuming they're not instances of |
| // some subclass of File. This is unlikely since we populate the items during |
| // option injection. The possibility still exists that constructors of |
| // initialized objects add objects that are instances of a File subclass. A |
| // safer approach would be to have a custom type that can be deferenced to |
| // access the resolved target file. This would also have the benefit of not |
| // having to modify any user collections and preserve the ordering. |
| |
| if (value instanceof File) { |
| File consideredFile = (File) value; |
| ResolvedFile resolvedFile = resolveRemoteFiles(consideredFile, option); |
| if (resolvedFile != null) { |
| File downloadedFile = resolvedFile.getResolvedFile(); |
| if (resolvedFile.shouldCleanUp()) { |
| downloadedFiles.add(downloadedFile); |
| } |
| // Replace the field value |
| try { |
| field.set(obj, downloadedFile); |
| } catch (IllegalAccessException e) { |
| CLog.e(e); |
| throw new BuildRetrievalError( |
| String.format( |
| "Failed to download %s due to '%s'", |
| consideredFile.getPath(), e.getMessage()), |
| e); |
| } |
| } |
| } else if (value instanceof Collection) { |
| @SuppressWarnings("unchecked") // Mostly-safe, see above comment. |
| Collection<Object> c = (Collection<Object>) value; |
| synchronized (c) { |
| Collection<Object> copy = new ArrayList<>(c); |
| for (Object o : copy) { |
| if (o instanceof File) { |
| File consideredFile = (File) o; |
| ResolvedFile resolvedFile = |
| resolveRemoteFiles(consideredFile, option); |
| if (resolvedFile != null) { |
| File downloadedFile = resolvedFile.getResolvedFile(); |
| if (resolvedFile.shouldCleanUp()) { |
| downloadedFiles.add(downloadedFile); |
| } |
| // TODO: See if order could be preserved. |
| c.remove(consideredFile); |
| c.add(downloadedFile); |
| } |
| } |
| } |
| } |
| } else if (value instanceof Map) { |
| @SuppressWarnings("unchecked") // Mostly-safe, see above comment. |
| Map<Object, Object> m = (Map<Object, Object>) value; |
| Map<Object, Object> copy = new LinkedHashMap<>(m); |
| for (Entry<Object, Object> entry : copy.entrySet()) { |
| Object key = entry.getKey(); |
| Object val = entry.getValue(); |
| |
| Object finalKey = key; |
| Object finalVal = val; |
| if (key instanceof File) { |
| ResolvedFile resolved = resolveRemoteFiles((File) key, option); |
| if (resolved != null) { |
| File downloaded = resolved.getResolvedFile(); |
| if (resolved.shouldCleanUp()) { |
| downloadedFiles.add(downloaded); |
| } |
| finalKey = downloaded; |
| } |
| } |
| if (val instanceof File) { |
| ResolvedFile resolved = resolveRemoteFiles((File) val, option); |
| if (resolved != null) { |
| File downloaded = resolved.getResolvedFile(); |
| if (resolved.shouldCleanUp()) { |
| downloadedFiles.add(downloaded); |
| } |
| finalVal = downloaded; |
| } |
| } |
| |
| m.remove(entry.getKey()); |
| m.put(finalKey, finalVal); |
| } |
| } else if (value instanceof MultiMap) { |
| @SuppressWarnings("unchecked") // Mostly-safe, see above comment. |
| MultiMap<Object, Object> m = (MultiMap<Object, Object>) value; |
| synchronized (m) { |
| MultiMap<Object, Object> copy = new MultiMap<>(m); |
| for (Object key : copy.keySet()) { |
| List<Object> mapValues = copy.get(key); |
| |
| m.remove(key); |
| Object finalKey = key; |
| if (key instanceof File) { |
| ResolvedFile resolved = resolveRemoteFiles((File) key, option); |
| if (resolved != null) { |
| File downloaded = resolved.getResolvedFile(); |
| if (resolved.shouldCleanUp()) { |
| downloadedFiles.add(downloaded); |
| } |
| finalKey = downloaded; |
| } |
| } |
| for (Object mapValue : mapValues) { |
| if (mapValue instanceof File) { |
| ResolvedFile resolvedFile = |
| resolveRemoteFiles((File) mapValue, option); |
| if (resolvedFile != null) { |
| if (resolvedFile.shouldCleanUp()) { |
| downloadedFiles.add(resolvedFile.getResolvedFile()); |
| } |
| mapValue = resolvedFile.getResolvedFile(); |
| } |
| } |
| m.put(finalKey, mapValue); |
| } |
| } |
| } |
| } |
| } |
| } |
| } catch (RuntimeException | BuildRetrievalError e) { |
| // Clean up the files before throwing |
| for (File f : downloadedFiles) { |
| FileUtil.recursiveDelete(f); |
| } |
| throw e; |
| } |
| return downloadedFiles; |
| } |
| |
| /** |
| * Download the files matching given filters in a remote zip file. |
| * |
| * <p>A file inside the remote zip file is only downloaded if its path matches any of the |
| * include filters but not the exclude filters. |
| * |
| * @param destDir the file to place the downloaded contents into. |
| * @param remoteZipFilePath the remote path to the zip file to download, relative to an |
| * implementation specific root. |
| * @param includeFilters a list of regex strings to download matching files. A file's path |
| * matching any filter will be downloaded. |
| * @param excludeFilters a list of regex strings to skip downloading matching files. A file's |
| * path matching any filter will not be downloaded. |
| * @throws BuildRetrievalError if files could not be downloaded. |
| */ |
| public void resolvePartialDownloadZip( |
| File destDir, |
| String remoteZipFilePath, |
| List<String> includeFilters, |
| List<String> excludeFilters) |
| throws BuildRetrievalError { |
| Map<String, String> queryArgs; |
| String protocol; |
| try { |
| URI uri = new URI(remoteZipFilePath); |
| protocol = uri.getScheme(); |
| queryArgs = parseQuery(uri.getQuery()); |
| } catch (URISyntaxException e) { |
| throw new BuildRetrievalError( |
| String.format( |
| "Failed to parse the remote zip file path: %s", remoteZipFilePath), |
| e); |
| } |
| queryArgs.put("partial_download_dir", destDir.getAbsolutePath()); |
| if (includeFilters != null) { |
| queryArgs.put("include_filters", String.join(";", includeFilters)); |
| } |
| if (excludeFilters != null) { |
| queryArgs.put("exclude_filters", String.join(";", excludeFilters)); |
| } |
| // Downloaded individual files should be saved to destDir, return value is not needed. |
| try { |
| IRemoteFileResolver resolver = getResolver(protocol); |
| resolver.setPrimaryDevice(mDevice); |
| RemoteFileResolverArgs args = new RemoteFileResolverArgs(); |
| args.setConsideredFile(new File(remoteZipFilePath)) |
| .addQueryArgs(queryArgs) |
| .setDestinationDir(destDir); |
| resolver.resolveRemoteFile(args); |
| } catch (BuildRetrievalError e) { |
| if (isOptional(queryArgs)) { |
| CLog.d( |
| "Failed to partially download '%s' but marked optional so skipping: %s", |
| remoteZipFilePath, e.getMessage()); |
| return; |
| } |
| |
| throw e; |
| } |
| } |
| |
| private IRemoteFileResolver getResolver(String protocol) throws BuildRetrievalError { |
| try { |
| return mFileResolverLoader.load(protocol, mExtraArgs); |
| } catch (ResolverLoadingException e) { |
| throw new BuildRetrievalError( |
| String.format("Could not load resolver for protocol %s", protocol), e); |
| } |
| } |
| |
| @VisibleForTesting |
| IGlobalConfiguration getGlobalConfig() { |
| return GlobalConfiguration.getInstance(); |
| } |
| |
| /** |
| * Utility that allows to check whether or not a file should be unzip and unzip it if required. |
| */ |
| public static final File unzipIfRequired(File downloadedFile, Map<String, String> query) |
| throws IOException { |
| String unzipValue = query.get(UNZIP_KEY); |
| if (unzipValue != null && "true".equals(unzipValue.toLowerCase())) { |
| if (downloadedFile.isDirectory()) { |
| return downloadedFile; |
| } |
| // File was requested to be unzipped. |
| try (CloseableTraceScope ignored = |
| new CloseableTraceScope("unzip " + downloadedFile.getName())) { |
| if (ZipUtil.isZipFileValid(downloadedFile, false)) { |
| File extractedDir = |
| FileUtil.createTempDir( |
| FileUtil.getBaseName(downloadedFile.getName()), |
| CurrentInvocation.getInfo(InvocationInfo.WORK_FOLDER)); |
| ZipUtil2.extractZip(downloadedFile, extractedDir); |
| FileUtil.deleteFile(downloadedFile); |
| return extractedDir; |
| } else { |
| throw new IOException( |
| String.format( |
| "%s was requested to be unzipped but is not a valid zip.", |
| downloadedFile)); |
| } |
| } |
| } |
| // Return the original file untouched |
| return downloadedFile; |
| } |
| |
| private ResolvedFile resolveRemoteFiles(File consideredFile, Option option) |
| throws BuildRetrievalError { |
| File fileToResolve; |
| String path = consideredFile.getPath(); |
| String protocol; |
| Map<String, String> query; |
| try { |
| URI uri = new URI(path.replace('\\','/')); |
| protocol = uri.getScheme(); |
| query = parseQuery(uri.getQuery()); |
| fileToResolve = new File(protocol + ":" + uri.getPath()); |
| } catch (URISyntaxException e) { |
| CLog.e(e); |
| throw new BuildRetrievalError( |
| e.getMessage(), e, InfraErrorIdentifier.OPTION_CONFIGURATION_ERROR); |
| } |
| query.put(OPTION_NAME_KEY, option.name()); |
| if (!mAllowParallelization) { |
| query.put(OPTION_PARALLEL_KEY, "false"); |
| } |
| |
| try { |
| IRemoteFileResolver resolver = getResolver(protocol); |
| if (resolver == null) { |
| return null; |
| } |
| CLog.d("Considering option '%s' with path: '%s' for download.", option.name(), path); |
| resolver.setPrimaryDevice(mDevice); |
| RemoteFileResolverArgs args = new RemoteFileResolverArgs(); |
| args.setConsideredFile(fileToResolve).addQueryArgs(query); |
| ResolvedFile resolvedFile = resolver.resolveRemoteFile(args); |
| if (resolvedFile != null && resolvedFile.getResolvedFile() instanceof ExtendedFile) { |
| // It is possible for dynamic download to download in parallel |
| // as long as they do so for the expected output file location |
| ExtendedFile trackingFile = (ExtendedFile) resolvedFile.getResolvedFile(); |
| if (trackingFile.isDownloadingInParallel()) { |
| mParallelExtendedFiles.add(trackingFile); |
| } |
| } |
| return resolvedFile; |
| } catch (BuildRetrievalError e) { |
| if (isOptional(query)) { |
| CLog.d( |
| "Failed to resolve '%s' but marked optional so skipping: %s", |
| fileToResolve, e.getMessage()); |
| return null; |
| } |
| |
| throw e; |
| } |
| } |
| |
| /** |
| * Parse a URL query style. Delimited by &, and map values represented by =. Example: |
| * ?key=value&key2=value2 |
| */ |
| private Map<String, String> parseQuery(String query) { |
| Map<String, String> values = new HashMap<>(); |
| if (query == null) { |
| return values; |
| } |
| for (String maps : query.split("&")) { |
| String[] keyVal = maps.split("="); |
| values.put(keyVal[0], keyVal[1]); |
| } |
| return values; |
| } |
| |
| /** Whether or not a link was requested as optional. */ |
| private boolean isOptional(Map<String, String> query) { |
| String value = query.get(OPTIONAL_KEY); |
| if (value == null) { |
| return false; |
| } |
| return "true".equals(value.toLowerCase()); |
| } |
| |
| /** Loads implementations of {@link IRemoteFileResolver}. */ |
| @VisibleForTesting |
| public interface FileResolverLoader { |
| /** |
| * Loads a resolver that can handle the provided scheme. |
| * |
| * @param scheme the URI scheme that the loaded resolver is expected to handle. |
| * @param config a map of all dynamic resolver configuration key-value pairs specified by |
| * the 'dynamic-resolver-args' TF command-line flag. |
| * @throws ResolverLoadingException if the resolver that handles the specified scheme cannot |
| * be loaded and/or initialized. |
| */ |
| @Nullable |
| IRemoteFileResolver load(String scheme, Map<String, String> config); |
| } |
| |
| /** Exception thrown if a resolver cannot be loaded or initialized. */ |
| @VisibleForTesting |
| static final class ResolverLoadingException extends HarnessRuntimeException { |
| public ResolverLoadingException(@Nullable String message, ErrorIdentifier errorId) { |
| super(message, errorId); |
| } |
| |
| public ResolverLoadingException(@Nullable String message, IHarnessException cause) { |
| super(message, cause); |
| } |
| } |
| |
| /** |
| * Loads and caches file resolvers using the service loading facility. |
| * |
| * <p>This implementation uses the service loading facility to find and cache available |
| * resolvers on the first call to {@code load}. |
| * |
| * <p>Any {@link Option}-annotated fields defined in loaded resolvers are initialized from the |
| * provided key-value pairs using the standard TF option-setting mechanism. Resolvers can define |
| * options that themselves require resolution as long as it causes no cycles during |
| * initialization. |
| * |
| * <p>Resolvers are loaded eagerly using ServiceLoader but have their options initialized only |
| * when first used. This avoids exceptions due to missing options in resolvers that are |
| * available on the class path but never used to load any files. |
| * |
| * <p>This implementation is thread-safe and ensures that any loaded resolvers are loaded at |
| * most once per instance. |
| */ |
| @ThreadSafe |
| @VisibleForTesting |
| static final class ServiceFileResolverLoader implements FileResolverLoader { |
| // We need the indirection since in production we use the context class loader that is |
| // defined when loading and not the one at construction. |
| private final Supplier<ClassLoader> mClassLoaderSupplier; |
| |
| @GuardedBy("this") |
| private @Nullable LoaderState mLoaderState; |
| |
| ServiceFileResolverLoader() { |
| mClassLoaderSupplier = () -> Thread.currentThread().getContextClassLoader(); |
| } |
| |
| ServiceFileResolverLoader(ClassLoader classLoader) { |
| mClassLoaderSupplier = () -> classLoader; |
| } |
| |
| @Override |
| public synchronized IRemoteFileResolver load(String scheme, Map<String, String> config) { |
| if (mLoaderState != null) { |
| return mLoaderState.getAndInit(scheme); |
| } |
| |
| // We use an intermediate map because the ImmutableMap builder throws if we add multiple |
| // entries with the same key. Note that we don't worry about setting any state that |
| // prevents this code from re-executing since failures loading service providers throws |
| // an Error which bubbles all the way to the top. |
| Map<String, IRemoteFileResolver> resolvers = new HashMap<>(); |
| ServiceLoader<IRemoteFileResolver> serviceLoader = |
| ServiceLoader.load(IRemoteFileResolver.class, mClassLoaderSupplier.get()); |
| |
| for (IRemoteFileResolver resolver : serviceLoader) { |
| resolvers.putIfAbsent(resolver.getSupportedProtocol(), resolver); |
| } |
| |
| mLoaderState = new LoaderState(resolvers, config); |
| return mLoaderState.getAndInit(scheme); |
| } |
| |
| /** Stores the state of loaded file resolvers. */ |
| private static final class LoaderState { |
| private final ImmutableMap<String, String> mConfig; |
| private final ImmutableMap<String, ResolverState> mState; |
| |
| LoaderState(Map<String, IRemoteFileResolver> resolvers, Map<String, String> config) { |
| this.mState = |
| ImmutableMap.copyOf( |
| Maps.transformValues(resolvers, r -> new ResolverState(r))); |
| this.mConfig = ImmutableMap.copyOf(config); |
| } |
| |
| /** Returns an initialized resolver instance for the specified scheme. */ |
| @Nullable |
| IRemoteFileResolver getAndInit(String scheme) { |
| ResolverState state = mState.get(scheme); |
| if (state == null) { |
| return null; |
| } |
| |
| return state.getAndInit(this); |
| } |
| |
| void resolve(IRemoteFileResolver resolver) |
| throws ConfigurationException, BuildRetrievalError { |
| // The device isn't set when resolving dynamic options because we don't want to load |
| // device-specific configuration when initializing pseudo-static resolvers that |
| // could out-live a particular device. |
| OptionSetter setter = new OptionSetter(resolver); |
| |
| for (Map.Entry<String, String> e : mConfig.entrySet()) { |
| String name = e.getKey(); |
| |
| // Note that we don't throw for options that don't exist. |
| if (setter.fieldsForArgNoThrow(name) == null) { |
| // TODO(hzalek): Consider throwing when the option doesn't exist and is |
| // qualified using one of the option source's aliases. |
| // option name uses one of |
| // the option source's aliases |
| continue; |
| } |
| |
| if (setter.isMapOption(name)) { |
| throw new ConfigurationException( |
| "Map options are not supported: " + name, |
| InfraErrorIdentifier.OPTION_CONFIGURATION_ERROR); |
| } |
| |
| setter.setOptionValue(name, e.getValue()); |
| } |
| |
| Collection<String> missingOptions = setter.getUnsetMandatoryOptions(); |
| if (!missingOptions.isEmpty()) { |
| throw new ConfigurationException( |
| String.format( |
| "Found missing mandatory options %s for resolver %s", |
| missingOptions, resolver.toString()), |
| InfraErrorIdentifier.OPTION_CONFIGURATION_ERROR); |
| } |
| |
| DynamicRemoteFileResolver dynamicResolver = |
| new DynamicRemoteFileResolver((scheme, unused) -> getAndInit(scheme)); |
| dynamicResolver.addExtraArgs(mConfig); |
| setter.validateRemoteFilePath(dynamicResolver); |
| } |
| |
| /** Stores the resolver and its initialization state. */ |
| static final class ResolverState { |
| final IRemoteFileResolver mResolver; |
| |
| /** |
| * The initialization state where {@code null} means never initialized, {@code |
| * false} means started, and {@code true} means done. |
| */ |
| @Nullable Boolean mDone; |
| |
| /** |
| * The exception thrown when initializing the resolver to ensure that we only do it |
| * once. |
| */ |
| @Nullable ResolverLoadingException mException; |
| |
| ResolverState(IRemoteFileResolver resolver) { |
| this.mResolver = resolver; |
| } |
| |
| IRemoteFileResolver getAndInit(LoaderState context) { |
| if (Boolean.TRUE.equals(mDone)) { |
| return getOrThrow(); |
| } |
| |
| if (Boolean.FALSE.equals(mDone)) { |
| // No need to catch or store the exception since it gets thrown in the |
| // recursive |
| // call to the dynamic resolver as a BuildRetrievalError which we already |
| // catch. |
| throw new ResolverLoadingException( |
| "Cycle detected while initializing resolver options: " |
| + mResolver.toString(), |
| InfraErrorIdentifier.OPTION_CONFIGURATION_ERROR); |
| } |
| |
| CLog.i("Initializing file resolver options: %s", mResolver); |
| mDone = Boolean.FALSE; |
| |
| try { |
| context.resolve(mResolver); |
| } catch (BuildRetrievalError | ConfigurationException e) { |
| mException = |
| new ResolverLoadingException( |
| "Could not initialize resolver options: " |
| + mResolver.toString(), |
| e); |
| throw mException; |
| } finally { |
| mDone = Boolean.TRUE; |
| } |
| |
| return mResolver; |
| } |
| |
| private IRemoteFileResolver getOrThrow() { |
| if (mException != null) { |
| throw mException; |
| } |
| return mResolver; |
| } |
| } |
| } |
| } |
| } |