blob: 54b502330de41b6a296629a0912f4a2b2d327e5f [file] [log] [blame]
/*
* Copyright (C) 2016 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.targetprep;
import com.android.annotations.VisibleForTesting;
import com.android.tradefed.build.IBuildInfo;
import com.android.tradefed.command.remote.DeviceDescriptor;
import com.android.tradefed.config.Option;
import com.android.tradefed.config.OptionClass;
import com.android.tradefed.device.DeviceNotAvailableException;
import com.android.tradefed.device.ITestDevice;
import com.android.tradefed.invoker.IInvocationContext;
import com.android.tradefed.log.LogUtil.CLog;
import com.android.tradefed.targetprep.multi.IMultiTargetPreparer;
import com.android.tradefed.util.CommandResult;
import com.android.tradefed.util.CommandStatus;
import com.android.tradefed.util.EnvUtil;
import com.android.tradefed.util.FileUtil;
import com.android.tradefed.util.IRunUtil;
import com.android.tradefed.util.RunUtil;
import com.android.tradefed.util.VtsFileUtil;
import com.android.tradefed.util.VtsPythonRunnerHelper;
import com.android.tradefed.util.VtsVendorConfigFileUtil;
import java.io.File;
import java.io.IOException;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.TreeMap;
import java.util.TreeSet;
/**
* Sets up a Python virtualenv on the host and installs packages. To activate it, the working
* directory is changed to the root of the virtualenv.
*
* This's a fork of PythonVirtualenvPreparer and is forked in order to simplify the change
* deployment process and reduce the deployment time, which are critical for VTS services.
* That means changes here will be upstreamed gradually.
*/
@OptionClass(alias = "python-venv")
public class VtsPythonVirtualenvPreparer implements IMultiTargetPreparer {
private static final String LOCAL_PYPI_PATH_ENV_VAR_NAME = "VTS_PYPI_PATH";
private static final String LOCAL_PYPI_PATH_KEY = "pypi_packages_path";
private static final int SECOND_IN_MSECS = 1000;
private static final int MINUTE_IN_MSECS = 60 * SECOND_IN_MSECS;
protected static int PIP_RETRY = 3;
private static final int PIP_RETRY_WAIT = 3 * SECOND_IN_MSECS;
protected static final int PIP_INSTALL_DELAY = SECOND_IN_MSECS;
public static final String VIRTUAL_ENV_V3 = "VIRTUAL_ENV_V3";
public static final String VIRTUAL_ENV = "VIRTUAL_ENV";
@Option(name = "venv-dir", description = "path of an existing virtualenv to use")
protected File mVenvDir = null;
@Option(name = "requirements-file", description = "pip-formatted requirements file")
private File mRequirementsFile = null;
@Option(name = "script-file", description = "scripts which need to be executed in advance")
private Collection<String> mScriptFiles = new TreeSet<>();
@Option(name = "dep-module", description = "modules which need to be installed by pip")
protected Collection<String> mDepModules = new LinkedHashSet<>();
@Option(name = "no-dep-module", description = "modules which should not be installed by pip")
private Collection<String> mNoDepModules = new TreeSet<>();
@Option(name = "reuse",
description = "Reuse an exising virtualenv path if exists in "
+ "temp directory. When this option is enabled, virtualenv directory used or "
+ "created by this preparer will not be deleted after tests complete.")
protected boolean mReuse = true;
@Option(name = "python-version",
description = "The version of a Python interpreter to use."
+ "Currently, only major version number is fully supported."
+ "Example: \"2\", or \"3\".")
private String mPythonVersion = "2";
private IBuildInfo mBuildInfo = null;
private DeviceDescriptor mDescriptor = null;
private IRunUtil mRunUtil = new RunUtil();
String mLocalPypiPath = null;
String mPipPath = null;
// Since we allow virtual env path to be reused during a test plan/module, only the preparer
// which created the directory should be the one to delete it.
private boolean mIsDirCreator = false;
// If the same object is used in multiple threads (in sharding mode), the class
// needs to know when it is safe to call the teardown method.
private int mNumOfInstances = 0;
// A map of initially installed pip modules and versions. Newly installed modules are not
// currently added automatically.
private Map<String, String> mPipInstallList = null;
/**
* {@inheritDoc}
*/
@Override
public synchronized void setUp(IInvocationContext context)
throws TargetSetupError, BuildError, DeviceNotAvailableException {
++mNumOfInstances;
mBuildInfo = context.getBuildInfos().get(0);
if (mNumOfInstances == 1) {
CLog.i("Preparing python dependencies...");
ITestDevice device = context.getDevices().get(0);
mDescriptor = device.getDeviceDescriptor();
initVirtualenv(mBuildInfo);
CLog.d("Python virtualenv path is: " + mVenvDir);
VtsPythonRunnerHelper.activateVirtualenv(getRunUtil(), mVenvDir.getAbsolutePath());
setLocalPypiPath();
installDeps();
}
addPathToBuild(mBuildInfo);
}
/**
* {@inheritDoc}
*/
@Override
public synchronized void tearDown(IInvocationContext context, Throwable e)
throws DeviceNotAvailableException {
--mNumOfInstances;
if (mNumOfInstances > 0) {
// Since this is a host side preparer, no need to repeat
return;
}
if (!mReuse && mVenvDir != null && mIsDirCreator) {
try {
recursiveDelete(mVenvDir.toPath());
CLog.d("Deleted the virtual env's temp working dir, %s.", mVenvDir);
} catch (IOException exception) {
CLog.e("Failed to delete %s: %s", mVenvDir, exception);
}
mVenvDir = null;
}
}
/**
* This method sets mLocalPypiPath, the local PyPI package directory to
* install python packages from in the installDeps method.
*/
protected void setLocalPypiPath() {
VtsVendorConfigFileUtil configReader = new VtsVendorConfigFileUtil();
if (configReader.LoadVendorConfig(mBuildInfo)) {
// First try to load local PyPI directory path from vendor config file
try {
String pypiPath = configReader.GetVendorConfigVariable(LOCAL_PYPI_PATH_KEY);
if (pypiPath.length() > 0 && dirExistsAndHaveReadAccess(pypiPath)) {
mLocalPypiPath = pypiPath;
CLog.d(String.format("Loaded %s: %s", LOCAL_PYPI_PATH_KEY, mLocalPypiPath));
}
} catch (NoSuchElementException e) {
/* continue */
}
}
// If loading path from vendor config file is unsuccessful,
// check local pypi path defined by LOCAL_PYPI_PATH_ENV_VAR_NAME
if (mLocalPypiPath == null) {
CLog.d("Checking whether local pypi packages directory exists");
String pypiPath = System.getenv(LOCAL_PYPI_PATH_ENV_VAR_NAME);
if (pypiPath == null) {
CLog.d("Local pypi packages directory not specified by env var %s",
LOCAL_PYPI_PATH_ENV_VAR_NAME);
} else if (dirExistsAndHaveReadAccess(pypiPath)) {
mLocalPypiPath = pypiPath;
CLog.d("Set local pypi packages directory to %s", pypiPath);
}
}
if (mLocalPypiPath == null) {
CLog.d("Failed to set local pypi packages path. Therefore internet connection to "
+ "https://pypi.python.org/simple/ must be available to run VTS tests.");
}
}
/**
* This method returns whether the given path is a dir that exists and the user has read access.
*/
private boolean dirExistsAndHaveReadAccess(String path) {
File pathDir = new File(path);
if (!pathDir.exists() || !pathDir.isDirectory()) {
CLog.d("Directory %s does not exist.", pathDir);
return false;
}
if (!EnvUtil.isOnWindows()) {
CommandResult c = getRunUtil().runTimedCmd(MINUTE_IN_MSECS, "ls", path);
if (c.getStatus() != CommandStatus.SUCCESS) {
CLog.d(String.format("Failed to read dir: %s. Result %s. stdout: %s, stderr: %s",
path, c.getStatus(), c.getStdout(), c.getStderr()));
return false;
}
return true;
} else {
try {
String[] pathDirList = pathDir.list();
if (pathDirList == null) {
CLog.d("Failed to read dir: %s. Please check access permission.", pathDir);
return false;
}
} catch (SecurityException e) {
CLog.d(String.format(
"Failed to read dir %s with SecurityException %s", pathDir, e));
return false;
}
return true;
}
}
/**
* Installs all python pip module dependencies specified in options.
* @throws TargetSetupError if failed
*/
protected void installDeps() throws TargetSetupError {
boolean hasDependencies = false;
if (!mScriptFiles.isEmpty()) {
for (String scriptFile : mScriptFiles) {
CLog.d("Attempting to execute a script, %s", scriptFile);
CommandResult c = getRunUtil().runTimedCmd(5 * MINUTE_IN_MSECS, scriptFile);
if (c.getStatus() != CommandStatus.SUCCESS) {
CLog.e("Executing script %s failed", scriptFile);
throw new TargetSetupError("Failed to source a script", mDescriptor);
}
}
}
if (mRequirementsFile != null) {
hasDependencies = true;
boolean success = false;
long retry_interval = PIP_RETRY_WAIT;
for (int try_count = 0; try_count < PIP_RETRY + 1; try_count++) {
if (try_count > 0) {
getRunUtil().sleep(retry_interval);
retry_interval *= 3;
}
if (installPipRequirementFile(mRequirementsFile)) {
success = true;
break;
}
}
if (!success) {
throw new TargetSetupError(
"Failed to install pip requirement file " + mRequirementsFile, mDescriptor);
}
}
if (!mDepModules.isEmpty()) {
for (String dep : mDepModules) {
hasDependencies = true;
if (mNoDepModules.contains(dep) || isPipModuleInstalled(dep)) {
continue;
}
boolean success = installPipModuleLocally(dep);
long retry_interval = PIP_RETRY_WAIT;
for (int retry_count = 0; retry_count < PIP_RETRY + 1; retry_count++) {
if (retry_count > 0) {
getRunUtil().sleep(retry_interval);
retry_interval *= 3;
}
if (success || (!success && installPipModule(dep))) {
success = true;
getRunUtil().sleep(PIP_INSTALL_DELAY);
break;
}
}
if (!success) {
throw new TargetSetupError("Failed to install pip module " + dep, mDescriptor);
}
}
}
if (!hasDependencies) {
CLog.d("No dependencies to install");
}
}
/**
* Installs a pip requirement file from Internet.
* @param req pip module requirement file object
* @return true if success. False otherwise
*/
private boolean installPipRequirementFile(File req) {
CommandResult result = getRunUtil().runTimedCmd(10 * MINUTE_IN_MSECS, getPipPath(),
"install", "-r", mRequirementsFile.getAbsolutePath());
CLog.d(String.format("Result %s. stdout: %s, stderr: %s", result.getStatus(),
result.getStdout(), result.getStderr()));
return result.getStatus() == CommandStatus.SUCCESS;
}
/**
* Installs a pip module from local directory.
* @param name of the module
* @return true if the module is successfully installed; false otherwise.
*/
private boolean installPipModuleLocally(String name) {
if (mLocalPypiPath == null) {
return false;
}
CLog.d("Attempting installation of %s from local directory", name);
CommandResult result = getRunUtil().runTimedCmd(5 * MINUTE_IN_MSECS, getPipPath(),
"install", name, "--no-index", "--find-links=" + mLocalPypiPath);
CLog.d(String.format("Result %s. stdout: %s, stderr: %s", result.getStatus(),
result.getStdout(), result.getStderr()));
return result.getStatus() == CommandStatus.SUCCESS;
}
/**
* Install a pip module from Internet
* @param name of the module
* @return true if success. False otherwise
*/
private boolean installPipModule(String name) {
CLog.d("Attempting installation of %s from PyPI", name);
CommandResult result =
getRunUtil().runTimedCmd(5 * MINUTE_IN_MSECS, getPipPath(), "install", name);
CLog.d("Result %s. stdout: %s, stderr: %s", result.getStatus(), result.getStdout(),
result.getStderr());
if (result.getStatus() != CommandStatus.SUCCESS) {
CLog.e("Installing %s from PyPI failed.", name);
CLog.d("Attempting to upgrade %s", name);
result = getRunUtil().runTimedCmd(
5 * MINUTE_IN_MSECS, getPipPath(), "install", "--upgrade", name);
CLog.d(String.format("Result %s. stdout: %s, stderr: %s", result.getStatus(),
result.getStdout(), result.getStderr()));
}
return result.getStatus() == CommandStatus.SUCCESS;
}
/**
* This method returns absolute pip path in virtualenv.
*
* This method is needed because although PATH is set in IRunUtil, IRunUtil will still
* use pip from system path.
*
* @return absolute pip path in virtualenv. null if virtualenv not available.
*/
public String getPipPath() {
if (mPipPath != null) {
return mPipPath;
}
String virtualenvPath = mVenvDir.getAbsolutePath();
if (virtualenvPath == null) {
return null;
}
mPipPath = new File(VtsPythonRunnerHelper.getPythonBinDir(virtualenvPath), "pip")
.getAbsolutePath();
return mPipPath;
}
/**
* Get the major python version from option.
*
* Currently, only 2 and 3 are supported.
*
* @return major version number
* @throws TargetSetupError
*/
protected int getConfiguredPythonVersionMajor() throws TargetSetupError {
if (mPythonVersion.startsWith("3.") || mPythonVersion.equals("3")) {
return 3;
} else if (mPythonVersion.startsWith("2.") || mPythonVersion.equals("2")) {
return 2;
} else {
throw new TargetSetupError("Unsupported python version " + mPythonVersion);
}
}
/**
* Add PYTHONPATH and VIRTUAL_ENV_PATH to BuildInfo.
* @param buildInfo
* @throws TargetSetupError
*/
protected void addPathToBuild(IBuildInfo buildInfo) throws TargetSetupError {
String target = null;
switch (getConfiguredPythonVersionMajor()) {
case 2:
target = VtsPythonVirtualenvPreparer.VIRTUAL_ENV;
break;
case 3:
target = VtsPythonVirtualenvPreparer.VIRTUAL_ENV_V3;
break;
}
if (!buildInfo.getBuildAttributes().containsKey(target)) {
buildInfo.addBuildAttribute(target, mVenvDir.getAbsolutePath());
}
}
/**
* Create virtualenv directory by executing virtualenv command.
* @param buildInfo
* @throws TargetSetupError
*/
protected void initVirtualenv(IBuildInfo buildInfo) throws TargetSetupError {
if (checkTestPlanLevelVirtualenv(buildInfo)) {
return;
}
try {
if (checkHostReuseVirtualenv(buildInfo)) {
return;
}
if (createVirtualenv()) {
return;
}
} catch (IOException | RuntimeException e) {
CLog.e(e);
}
CLog.e(String.format("Failed to create virtualenv at %s.", mVenvDir));
throw new TargetSetupError("Error creating virtualenv", mDescriptor);
}
protected File getVirtualenvCreationMarkFile() {
return new File(mVenvDir, "complete");
}
/**
* Completes the creation of virtualenv.
* @return true if the directory is successfully prepared as virutalenv; false otherwise
* @throws IOException if completion mark file creation failed.
*/
protected boolean createVirtualenv() throws IOException {
CLog.d("Creating virtualenv at " + mVenvDir);
String[] cmd = new String[] {
"virtualenv", "-p", "python" + mPythonVersion, mVenvDir.getAbsolutePath()};
long waitRetryCreate = 5 * SECOND_IN_MSECS;
for (int try_count = 0; try_count < PIP_RETRY + 1; try_count++) {
if (try_count > 0) {
getRunUtil().sleep(waitRetryCreate);
}
CommandResult c = getRunUtil().runTimedCmd(3 * MINUTE_IN_MSECS, cmd);
if (c.getStatus() != CommandStatus.SUCCESS) {
String message_lower = (c.getStdout() + c.getStderr()).toLowerCase();
if (message_lower.contains("errno 17") // File exists
|| message_lower.contains("errno 26")
|| message_lower.contains("text file busy")) {
// Race condition, retry.
CLog.d("detected the virtualenv path is being created by other process.");
if (createVirtualenv_waitForOtherProcessToCreateVirtualEnv()) {
CLog.d("detected the other process has created virtualenv.");
return true;
}
} else {
// Other error, abort.
CLog.e(String.format("Exit code: %s, stdout: %s, stderr: %s", c.getStatus(),
c.getStdout(), c.getStderr()));
break;
}
} else {
mIsDirCreator = true;
getVirtualenvCreationMarkFile().createNewFile();
CLog.d("Succesfully created virtualenv at " + mVenvDir);
return true;
}
}
return false;
}
/**
* Checks whether a host-wise virutanenv directory can be used. If not, creates a empty one.
* @param buildInfo
* @return true if a host-wise virutanenv directory can be used; false otherwise.
* @throws IOException if failed to create empty directory for the virtualenv path.
*/
protected boolean checkHostReuseVirtualenv(IBuildInfo buildInfo) throws IOException {
if (mReuse) {
String tempDir = System.getProperty("java.io.tmpdir");
mVenvDir = new File(tempDir, "vts-virtualenv-" + mPythonVersion);
if (mVenvDir.exists()) {
if (createVirtualenv_waitForOtherProcessToCreateVirtualEnv()) {
CLog.d("Using existing virtualenv for version " + mPythonVersion);
return true;
}
}
} else {
mVenvDir = FileUtil.createTempDir("vts-virtualenv-" + mPythonVersion + "-"
+ VtsFileUtil.normalizeFileName(buildInfo.getTestTag()) + "_");
}
return false;
}
/**
* Checks whether a test plan-wise common virtualenv directory can be used.
* @param buildInfo
* @return true if a test plan-wise virtuanenv directory exists; false otherwise
* @throws TargetSetupError
*/
protected boolean checkTestPlanLevelVirtualenv(IBuildInfo buildInfo) throws TargetSetupError {
if (mVenvDir == null) {
String venvDir = null;
switch (getConfiguredPythonVersionMajor()) {
case 2:
venvDir = buildInfo.getBuildAttributes().get(
VtsPythonVirtualenvPreparer.VIRTUAL_ENV);
break;
case 3:
venvDir = buildInfo.getBuildAttributes().get(
VtsPythonVirtualenvPreparer.VIRTUAL_ENV_V3);
break;
}
if (venvDir != null) {
mVenvDir = new File(venvDir);
}
}
if (mVenvDir != null) {
return true;
}
return false;
}
/**
* Wait for another process to finish creating virtualenv path.
* @return true if creation is detected a success; false otherwise.
*/
protected boolean createVirtualenv_waitForOtherProcessToCreateVirtualEnv() {
long start = System.currentTimeMillis();
long totalWaitCheckComplete = 3 * MINUTE_IN_MSECS;
long waitRetryCheckComplete = SECOND_IN_MSECS / 2;
while (true) {
if (getVirtualenvCreationMarkFile().exists()) {
return true;
}
if (System.currentTimeMillis() - start < totalWaitCheckComplete) {
getRunUtil().sleep(waitRetryCheckComplete);
} else {
break;
}
}
return false;
}
protected void addDepModule(String module) {
mDepModules.add(module);
}
protected void setRequirementsFile(File f) {
mRequirementsFile = f;
}
/**
* Get an instance of {@link IRunUtil}.
*/
@VisibleForTesting
protected IRunUtil getRunUtil() {
if (mRunUtil == null) {
mRunUtil = new RunUtil();
}
return mRunUtil;
}
/**
* This method recursively deletes a file tree without following symbolic links.
*
* @param rootPath the path to delete.
* @throws IOException if fails to traverse or delete the files.
*/
private static void recursiveDelete(Path rootPath) throws IOException {
Files.walkFileTree(rootPath, new SimpleFileVisitor<Path>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs)
throws IOException {
Files.delete(file);
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult postVisitDirectory(Path dir, IOException e) throws IOException {
if (e != null) {
throw e;
}
Files.delete(dir);
return FileVisitResult.CONTINUE;
}
});
}
/**
* Locally checks whether a pip module is installed.
*
* This read the installed module list from command "pip list" and check whether the
* module in requirement string is installed and its version satisfied.
*
* Note: This method is only a help method for speed optimization purpose.
* It does not check dependencies of the module.
* It replace dots "." in module name with dash "-".
* If the "pip list" command failed, it will return false and will not throw exception
* It can also only accept one ">=" version requirement string.
* If this method returns false, the requirement should still be checked using pip itself.
*
* @param requirement such as "numpy", "pip>=9"
* @return True if module is installed locally with correct version. False otherwise
*/
private boolean isPipModuleInstalled(String requirement) {
if (mPipInstallList == null) {
mPipInstallList = getInstalledPipModules();
if (mPipInstallList == null) {
CLog.e("Failed to read local pip install list.");
return false;
}
}
String name;
String version = null;
if (requirement.contains(">=")) {
String[] tokens = requirement.split(">=");
if (tokens.length != 2) {
return false;
}
name = tokens[0];
version = tokens[1];
} else if (requirement.contains("=") || requirement.contains("<")
|| requirement.contains(">")) {
return false;
} else {
name = requirement;
}
name = name.replaceAll("\\.", "-");
if (!mPipInstallList.containsKey(name)) {
return false;
}
// TODO: support other comparison and multiple condition if there's a use case.
if (version != null && !isVersionGreaterEqual(mPipInstallList.get(name), version)) {
return false;
}
return true;
}
/**
* Compares whether version string 1 is greater or equal to version string 2
* @param version1
* @param version2
* @return True if the value of version1 >= version2
*/
private static boolean isVersionGreaterEqual(String version1, String version2) {
version1 = version1.replaceAll("[^0-9.]+", "");
version2 = version2.replaceAll("[^0-9.]+", "");
String[] tokens1 = version1.split("\\.");
String[] tokens2 = version2.split("\\.");
int length = Math.max(tokens1.length, tokens2.length);
for (int i = 0; i < length; i++) {
try {
int token1 = i < tokens1.length ? Integer.parseInt(tokens1[i]) : 0;
int token2 = i < tokens2.length ? Integer.parseInt(tokens2[i]) : 0;
if (token1 < token2) {
return false;
}
} catch (NumberFormatException e) {
CLog.e("failed to compare pip module version: %s and %s", version1, version2);
return false;
}
}
return true;
}
/**
* Gets map of installed pip packages and their versions.
* @return installed pip packages
*/
private Map<String, String> getInstalledPipModules() {
CommandResult res = getRunUtil().runTimedCmd(30 * SECOND_IN_MSECS, getPipPath(), "list");
if (res.getStatus() != CommandStatus.SUCCESS) {
CLog.e(String.format("Failed to read pip installed list: "
+ "Result %s. stdout: %s, stderr: %s",
res.getStatus(), res.getStdout(), res.getStderr()));
return null;
}
String raw = res.getStdout();
String[] lines = raw.split("\\r?\\n");
TreeMap<String, String> pipInstallList = new TreeMap<>();
for (String line : lines) {
line = line.trim();
if (line.length() == 0 || line.startsWith("Package ") || line.startsWith("-")) {
continue;
}
String[] tokens = line.split("\\s+");
if (tokens.length != 2) {
CLog.e("Error parsing pip installed package list. Line text: " + line);
continue;
}
pipInstallList.put(tokens[0], tokens[1]);
}
return pipInstallList;
}
}