[Tradefed] Open source clustercommandscheduler

Test: Unit test passed
Bug: 144734056

Change-Id: If26f800d1b64a303a081076d8c303ed6cd13ee2a
Merged-In: If26f800d1b64a303a081076d8c303ed6cd13ee2a
diff --git a/src/com/android/tradefed/cluster/ClusterBuildInfo.java b/src/com/android/tradefed/cluster/ClusterBuildInfo.java
new file mode 100644
index 0000000..f57df27
--- /dev/null
+++ b/src/com/android/tradefed/cluster/ClusterBuildInfo.java
@@ -0,0 +1,30 @@
+/*
+ * Copyright (C) 2019 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.cluster;
+
+import com.android.tradefed.build.FolderBuildInfo;
+import com.android.tradefed.build.IBuildInfo;
+
+import java.io.File;
+
+/** A {@link IBuildInfo} class for builds piped from TFC. */
+public class ClusterBuildInfo extends FolderBuildInfo {
+
+    public ClusterBuildInfo(final File rootDir) {
+        super();
+        setRootDir(rootDir);
+    }
+}
diff --git a/src/com/android/tradefed/cluster/ClusterBuildProvider.java b/src/com/android/tradefed/cluster/ClusterBuildProvider.java
new file mode 100644
index 0000000..b3b67c1
--- /dev/null
+++ b/src/com/android/tradefed/cluster/ClusterBuildProvider.java
@@ -0,0 +1,94 @@
+/*
+ * Copyright (C) 2019 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.cluster;
+
+import com.android.annotations.VisibleForTesting;
+import com.android.tradefed.build.BuildRetrievalError;
+import com.android.tradefed.build.IBuildInfo;
+import com.android.tradefed.build.IBuildProvider;
+import com.android.tradefed.config.Option;
+import com.android.tradefed.config.OptionClass;
+import com.android.tradefed.util.ZipUtil2;
+
+import org.apache.commons.compress.archivers.zip.ZipFile;
+import java.io.File;
+import java.io.IOException;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.TreeMap;
+
+/** A {@link IBuildProvider} to download TFC test resources. */
+@OptionClass(alias = "cluster", global_namespace = false)
+public class ClusterBuildProvider implements IBuildProvider {
+
+    private static final String DEFAULT_FILE_VERSION = "0";
+
+    @Option(name = "root-dir", description = "A root directory", mandatory = true)
+    private File mRootDir;
+
+    @Option(name = "test-resource", description = "Test resources", mandatory = true)
+    private Map<String, String> mTestResources = new TreeMap<>();
+
+    @Override
+    public IBuildInfo getBuild() throws BuildRetrievalError {
+        try {
+            mRootDir.mkdirs();
+            final IBuildInfo buildInfo = new ClusterBuildInfo(mRootDir);
+            final TestResourceDownloader downloader = new TestResourceDownloader(mRootDir);
+            for (final Entry<String, String> entry : mTestResources.entrySet()) {
+                final TestResource resource = new TestResource(entry.getKey(), entry.getValue());
+                final File file = downloader.download(resource);
+                buildInfo.setFile(resource.getName(), file, DEFAULT_FILE_VERSION);
+                if (file.getName().endsWith(".zip")) {
+                    // If a zip file is downloaded to a subfolder, unzip there.
+                    extractZip(file, file.getParentFile());
+                }
+            }
+            return buildInfo;
+        } catch (IOException e) {
+            throw new BuildRetrievalError("failed to get test resources", e);
+        }
+    }
+
+    /** Extracts the zip to a root dir. */
+    private void extractZip(File zip, File destDir) throws IOException {
+        try (ZipFile zipFile = new ZipFile(zip)) {
+            ZipUtil2.extractZip(zipFile, destDir);
+        } catch (IOException e) {
+            throw e;
+        }
+    }
+
+    @Override
+    public void buildNotTested(IBuildInfo info) {}
+
+    @Override
+    public void cleanUp(IBuildInfo info) {
+        if (!(info instanceof ClusterBuildInfo)) {
+            throw new IllegalArgumentException("info is not an instance of ClusterBuildInfo");
+        }
+    }
+
+    @VisibleForTesting
+    File getRootDir() {
+        return mRootDir;
+    }
+
+    @VisibleForTesting
+    Map<String, String> getTestResources() {
+        return mTestResources;
+    }
+}
diff --git a/src/com/android/tradefed/cluster/ClusterClient.java b/src/com/android/tradefed/cluster/ClusterClient.java
new file mode 100644
index 0000000..b9d7fd2
--- /dev/null
+++ b/src/com/android/tradefed/cluster/ClusterClient.java
@@ -0,0 +1,354 @@
+/*
+ * Copyright (C) 2019 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.cluster;
+
+import com.android.tradefed.config.GlobalConfiguration;
+import com.android.tradefed.host.IHostOptions;
+import com.android.tradefed.log.LogUtil.CLog;
+import com.android.tradefed.util.StreamUtil;
+import com.android.tradefed.util.IRestApiHelper;
+import com.android.tradefed.util.RestApiHelper;
+
+import com.google.api.client.http.HttpRequestFactory;
+import com.google.api.client.http.HttpResponse;
+import com.google.api.client.http.HttpTransport;
+import com.google.api.client.http.javanet.NetHttpTransport;
+import com.google.common.annotations.VisibleForTesting;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/** A {@link IClusterClient} implementation for interacting with the TFC backend. */
+public class ClusterClient implements IClusterClient {
+    private static final String DEFAULT_TFC_URL_BASE =
+            "https://tradefed-cluster.googleplex.com/_ah/api/tradefed_cluster/v1/";
+
+    /** The {@link IClusterOptions} instance used to store cluster-related settings. */
+    private IClusterOptions mClusterOptions;
+    /** The {@link IHostOptions} instance used to store host wide related settings. */
+    private IHostOptions mHostOptions;
+
+    private IRestApiHelper mApiHelper = null;
+
+    private IClusterEventUploader<ClusterCommandEvent> mCommandEventUploader = null;
+    private IClusterEventUploader<ClusterHostEvent> mHostEventUploader = null;
+
+    /**
+     * A {@link IClusterEventUploader} implementation for uploading {@link ClusterCommandEvent}s.
+     */
+    private class ClusterCommandEventUploader extends ClusterEventUploader<ClusterCommandEvent> {
+
+        private static final String REST_API_METHOD = "command_events";
+        private static final String DATA_KEY = "command_events";
+
+        @Override
+        protected void doUploadEvents(List<ClusterCommandEvent> events) throws IOException {
+            try {
+                JSONObject eventData = buildPostData(events, DATA_KEY);
+                getApiHelper().execute("POST", new String[] {REST_API_METHOD}, null, eventData);
+            } catch (JSONException e) {
+                throw new IOException(e);
+            }
+        }
+    }
+
+    /** A {@link IClusterEventUploader} implementation for uploading {@link ClusterHostEvent}s. */
+    private class ClusterHostEventUploader extends ClusterEventUploader<ClusterHostEvent> {
+        private static final String REST_API_METHOD = "host_events";
+        private static final String DATA_KEY = "host_events";
+
+        @Override
+        protected void doUploadEvents(List<ClusterHostEvent> events) throws IOException {
+            try {
+                JSONObject eventData = buildPostData(events, DATA_KEY);
+                getApiHelper().execute("POST", new String[] {REST_API_METHOD}, null, eventData);
+            } catch (JSONException e) {
+                throw new IOException(e);
+            }
+        }
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public List<ClusterCommand> leaseHostCommands(
+            final String clusterId,
+            final String hostname,
+            final List<ClusterDeviceInfo> deviceInfos,
+            final List<String> nextClusterIds,
+            final int maxTasksTolease)
+            throws JSONException {
+        // Make an API call to lease some work
+        final Map<String, Object> options = new HashMap<>();
+        options.put("cluster", clusterId);
+        options.put("hostname", hostname);
+        options.put("num_tasks", Integer.toString(maxTasksTolease));
+
+        JSONObject data = new JSONObject();
+        if (nextClusterIds != null && !nextClusterIds.isEmpty()) {
+            JSONArray ids = new JSONArray();
+            for (String id : nextClusterIds) {
+                ids.put(id);
+            }
+            data.put("next_cluster_ids", ids);
+        }
+        JSONArray deviceInfoJsons = new JSONArray();
+        for (ClusterDeviceInfo d : deviceInfos) {
+            deviceInfoJsons.put(d.toJSON());
+        }
+        // Add device infos in the request body. TFC will match devices based on those infos.
+        data.put("device_infos", deviceInfoJsons);
+        try {
+            // By default, execute(..) will throw an exception on HTTP error codes.
+            HttpResponse httpResponse =
+                    getApiHelper()
+                            .execute(
+                                    "POST",
+                                    new String[] {"tasks", "leasehosttasks"},
+                                    options,
+                                    data);
+            return parseCommandTasks(httpResponse);
+        } catch (IOException e) {
+            // May be transient. Log a warning and we'll try again later.
+            CLog.w("Failed to lease commands: %s", e);
+            return Collections.<ClusterCommand>emptyList();
+        }
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public TestEnvironment getTestEnvironment(final String requestId)
+            throws IOException, JSONException {
+        final Map<String, Object> options = new HashMap<>();
+        final HttpResponse response =
+                getApiHelper()
+                        .execute(
+                                "GET",
+                                new String[] {"requests", requestId, "test_environment"},
+                                options,
+                                null);
+        final String content = StreamUtil.getStringFromStream(response.getContent());
+        CLog.d(content);
+        return TestEnvironment.fromJson(new JSONObject(content));
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public List<TestResource> getTestResources(final String requestId)
+            throws IOException, JSONException {
+        final Map<String, Object> options = new HashMap<>();
+        final HttpResponse response =
+                getApiHelper()
+                        .execute(
+                                "GET",
+                                new String[] {"requests", requestId, "test_resources"},
+                                options,
+                                null);
+        final String content = StreamUtil.getStringFromStream(response.getContent());
+        CLog.d(content);
+        final JSONArray jsonArray = new JSONObject(content).optJSONArray("test_resources");
+        if (jsonArray == null) {
+            return new ArrayList<>();
+        }
+        return TestResource.fromJsonArray(jsonArray);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public TestContext getTestContext(final String requestId, final String commandId)
+            throws IOException, JSONException {
+        final Map<String, Object> options = new HashMap<>();
+        final HttpResponse response =
+                getApiHelper()
+                        .execute(
+                                "GET",
+                                new String[] {
+                                    "requests", requestId, "commands", commandId, "test_context"
+                                },
+                                options,
+                                null);
+        final String content = StreamUtil.getStringFromStream(response.getContent());
+        CLog.d(content);
+        return TestContext.fromJson(new JSONObject(content));
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public void updateTestContext(
+            final String requestId, final String commandId, TestContext testContext)
+            throws IOException, JSONException {
+        final Map<String, Object> options = new HashMap<>();
+        final HttpResponse response =
+                getApiHelper()
+                        .execute(
+                                "POST",
+                                new String[] {
+                                    "requests", requestId, "commands", commandId, "test_context"
+                                },
+                                options,
+                                testContext.toJson());
+        final String content = StreamUtil.getStringFromStream(response.getContent());
+        CLog.d(content);
+    }
+
+    @Override
+    public ClusterCommand.State getCommandState(String requestId, String commandId) {
+        try {
+            HttpResponse response =
+                    getApiHelper()
+                            .execute(
+                                    "GET",
+                                    new String[] {"requests", requestId, "commands", commandId},
+                                    new HashMap<>(),
+                                    null);
+            String content = StreamUtil.getStringFromStream(response.getContent());
+            String value = new JSONObject(content).getString("state");
+            return ClusterCommand.State.valueOf(value);
+        } catch (IOException | JSONException | IllegalArgumentException e) {
+            CLog.w("Failed to get state of request %s command %s", requestId, commandId);
+            CLog.e(e);
+            return ClusterCommand.State.UNKNOWN;
+        }
+    }
+
+    /**
+     * Parse command tasks from the http response
+     *
+     * @param httpResponse the http response
+     * @return a list of command tasks
+     * @throws IOException
+     */
+    private static List<ClusterCommand> parseCommandTasks(HttpResponse httpResponse)
+            throws IOException {
+        String response = "";
+        InputStream content = httpResponse.getContent();
+        if (content == null) {
+            throw new IOException("null response");
+        }
+        response = StreamUtil.getStringFromStream(content);
+        // Parse the response
+        try {
+            JSONObject jsonResponse = new JSONObject(response);
+            if (jsonResponse.has("tasks")) {
+                // Convert the JSON commands to ClusterCommand objects
+                JSONArray jsonCommands = jsonResponse.getJSONArray("tasks");
+                List<ClusterCommand> commandTasks = new ArrayList<>(jsonCommands.length());
+                for (int i = 0; i < jsonCommands.length(); i++) {
+                    JSONObject jsonCommand = jsonCommands.getJSONObject(i);
+                    commandTasks.add(ClusterCommand.fromJson(jsonCommand));
+                }
+                return commandTasks;
+            } else {
+                // No work to be done
+                return Collections.<ClusterCommand>emptyList();
+            }
+        } catch (JSONException e) {
+            // May be a server-side issue. Log a warning and we'll try again later.
+            CLog.w("Failed to parse response from server: %s", response);
+            return Collections.<ClusterCommand>emptyList();
+        }
+    }
+
+    /**
+     * Helper method to convert a list of {@link IClusterEvent}s into a json format that TFC can
+     * understand.
+     *
+     * @param events The list of events to convert
+     * @param key The key string to use to identify the list of events
+     */
+    private static JSONObject buildPostData(List<? extends IClusterEvent> events, String key)
+            throws JSONException {
+
+        JSONArray array = new JSONArray();
+        for (IClusterEvent event : events) {
+            array.put(event.toJSON());
+        }
+        JSONObject postData = new JSONObject();
+        postData.put(key, array);
+        return postData;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public IClusterEventUploader<ClusterCommandEvent> getCommandEventUploader() {
+        if (mCommandEventUploader == null) {
+            mCommandEventUploader = new ClusterCommandEventUploader();
+        }
+        return mCommandEventUploader;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public IClusterEventUploader<ClusterHostEvent> getHostEventUploader() {
+        if (mHostEventUploader == null) {
+            mHostEventUploader = new ClusterHostEventUploader();
+        }
+        return mHostEventUploader;
+    }
+
+    /**
+     * Get the shared {@link IRestApiHelper} instance.
+     *
+     * <p>Exposed for testing.
+     *
+     * @return the shared {@link IRestApiHelper} instance.
+     */
+    @VisibleForTesting
+    IRestApiHelper getApiHelper() {
+        if (mApiHelper == null) {
+            HttpTransport transport = null;
+            transport = new NetHttpTransport();
+            HttpRequestFactory requestFactory = transport.createRequestFactory();
+            String baseUrl = getClusterOptions().getServiceUrl();
+            if (baseUrl == null) {
+                baseUrl = DEFAULT_TFC_URL_BASE;
+            }
+            mApiHelper = new RestApiHelper(requestFactory, baseUrl);
+        }
+        return mApiHelper;
+    }
+
+    /** Get the {@link IClusterOptions} instance used to store cluster-related settings. */
+    IClusterOptions getClusterOptions() {
+        if (mClusterOptions == null) {
+            mClusterOptions =
+                    (IClusterOptions)
+                            GlobalConfiguration.getInstance()
+                                    .getConfigurationObject(ClusterOptions.TYPE_NAME);
+            if (mClusterOptions == null) {
+                throw new IllegalStateException(
+                        "cluster_options not defined. You must add this "
+                                + "object to your global config. See google/atp/cluster.xml.");
+            }
+        }
+        return mClusterOptions;
+    }
+
+    IHostOptions getHostOptions() {
+        if (mHostOptions == null) {
+            mHostOptions = GlobalConfiguration.getInstance().getHostOptions();
+        }
+        return mHostOptions;
+    }
+}
diff --git a/src/com/android/tradefed/cluster/ClusterCommand.java b/src/com/android/tradefed/cluster/ClusterCommand.java
new file mode 100644
index 0000000..a821f99
--- /dev/null
+++ b/src/com/android/tradefed/cluster/ClusterCommand.java
@@ -0,0 +1,225 @@
+/*
+ * Copyright (C) 2019 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.cluster;
+
+import com.google.common.base.Strings;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+/** A class that represents a task fetched from TF Cluster. */
+public class ClusterCommand {
+
+    public static enum RequestType {
+        /** An unmanaged request: the command line will run as is by the current TF process. */
+        UNMANAGED,
+        /** A managed request: the command line will run by a new TF process. */
+        MANAGED;
+    }
+
+    /** Command's status in the TF cluster. */
+    public static enum State {
+        /** Initial state, or failed to determine state. */
+        UNKNOWN,
+        /** Inserted into the cluster's queue. */
+        QUEUED,
+        /** Currently being executed. */
+        RUNNING,
+        /** Canceled by user, or failed to allocate a device. */
+        CANCELED,
+        /** Completed successfully. */
+        COMPLETED,
+        /** Completed exceptionally. */
+        ERROR,
+        /** Non-retryable error, e.g. invalid configuration. */
+        FATAL
+    }
+
+    private final String mTaskId;
+    private final String mRequestId;
+    private final String mCommandId;
+    private final String mCommandLine;
+    private final RequestType mRequestType;
+    private final Integer mShardCount;
+    private final Integer mShardIndex;
+    private final String mAttemptId;
+    // Devices try to match the current command.
+    private List<String> mTargetDeviceSerials = new ArrayList<>();
+
+    public ClusterCommand(String commandId, String taskId, String cmdLine) {
+        this(null, commandId, taskId, cmdLine, null, RequestType.UNMANAGED, null, null);
+    }
+
+    /**
+     * Constructor.
+     *
+     * @param requestId A request ID
+     * @param commandId The ID of the command that issued this task
+     * @param taskId The ID of this task
+     * @param cmdLine The command line to run
+     * @param requestType A request type
+     * @param shardCount A shard count
+     * @param shardIndex A shard index
+     */
+    public ClusterCommand(
+            String requestId,
+            String commandId,
+            String taskId,
+            String cmdLine,
+            String attemptId,
+            RequestType requestType,
+            Integer shardCount,
+            Integer shardIndex) {
+        mTaskId = taskId;
+        mRequestId = requestId;
+        mCommandId = commandId;
+        mCommandLine = cmdLine;
+        mRequestType = requestType;
+        mShardCount = shardCount;
+        mShardIndex = shardIndex;
+        if (!Strings.isNullOrEmpty(attemptId)) {
+            mAttemptId = attemptId;
+        } else {
+            // TODO(b/123294120): Remove creating attemptId on TF side once
+            // b/123294120 rolls out.
+            mAttemptId = UUID.randomUUID().toString();
+        }
+    }
+
+    /**
+     * Returns the task ID.
+     *
+     * @return task ID.
+     */
+    public String getTaskId() {
+        return mTaskId;
+    }
+
+    /**
+     * Returns the request ID.
+     *
+     * @return the request ID
+     */
+    public String getRequestId() {
+        return mRequestId;
+    }
+
+    /**
+     * Returns the command ID.
+     *
+     * @return the command ID
+     */
+    public String getCommandId() {
+        return mCommandId;
+    }
+
+    /**
+     * Returns the attempt ID. The attempt is randomly generated GUID used to distinguish multiple
+     * command runs.
+     *
+     * @return the attempt ID
+     */
+    public String getAttemptId() {
+        return mAttemptId;
+    }
+
+    /**
+     * Returns the command line string.
+     *
+     * @return the command line string.
+     */
+    public String getCommandLine() {
+        return mCommandLine;
+    }
+
+    /**
+     * Returns a request type
+     *
+     * @return a request type
+     */
+    public RequestType getRequestType() {
+        return mRequestType;
+    }
+
+    /**
+     * Returns the list of target device serials on which this command will attempt to run.
+     *
+     * @return the list of target device serials
+     */
+    public List<String> getTargetDeviceSerials() {
+        return mTargetDeviceSerials;
+    }
+
+    /**
+     * Sets the list of target device serials on which the command will try to run.
+     *
+     * @param targetDeviceSerials the list of device serials to set
+     */
+    public void setTargetDeviceSerials(List<String> targetDeviceSerials) {
+        this.mTargetDeviceSerials = targetDeviceSerials;
+    }
+
+    /**
+     * Returns a shard count.
+     *
+     * @return a shard count.
+     */
+    public Integer getShardCount() {
+        return mShardCount;
+    }
+
+    /**
+     * Returns a shard index.
+     *
+     * @return a shard index.
+     */
+    public Integer getShardIndex() {
+        return mShardIndex;
+    }
+
+    private static Integer optInteger(JSONObject json, String name) throws JSONException {
+        if (json.isNull(name)) {
+            return null;
+        }
+        return json.getInt(name);
+    }
+
+    public static ClusterCommand fromJson(JSONObject json) throws JSONException {
+        ClusterCommand command =
+                new ClusterCommand(
+                        json.getString("request_id"),
+                        json.getString("command_id"),
+                        json.getString("task_id"),
+                        json.getString("command_line"),
+                        json.optString("attempt_id", null),
+                        RequestType.valueOf(
+                                json.optString("request_type", RequestType.UNMANAGED.name())),
+                        optInteger(json, "shard_count"),
+                        optInteger(json, "shard_index"));
+        JSONArray jsonDeviceSerials = json.optJSONArray("device_serials");
+        if (jsonDeviceSerials != null) {
+            final List<String> deviceSerials = new ArrayList<>();
+            for (int j = 0; j < jsonDeviceSerials.length(); j++) {
+                deviceSerials.add(jsonDeviceSerials.getString(j));
+            }
+            command.setTargetDeviceSerials(deviceSerials);
+        }
+        return command;
+    }
+}
diff --git a/src/com/android/tradefed/cluster/ClusterCommandConfigBuilder.java b/src/com/android/tradefed/cluster/ClusterCommandConfigBuilder.java
new file mode 100644
index 0000000..8669f0b
--- /dev/null
+++ b/src/com/android/tradefed/cluster/ClusterCommandConfigBuilder.java
@@ -0,0 +1,278 @@
+/*
+ * Copyright (C) 2019 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.cluster;
+
+import com.android.tradefed.config.ArgsOptionParser;
+import com.android.tradefed.config.Configuration;
+import com.android.tradefed.config.ConfigurationException;
+import com.android.tradefed.config.DeviceConfigurationHolder;
+import com.android.tradefed.config.IConfiguration;
+import com.android.tradefed.config.IDeviceConfiguration;
+import com.android.tradefed.log.SimpleFileLogger;
+import com.android.tradefed.result.ITestInvocationListener;
+import com.android.tradefed.util.MultiMap;
+import com.android.tradefed.util.StringUtil;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+
+/** A class to build a configuration file for a cluster command. */
+public class ClusterCommandConfigBuilder {
+
+    private static final String TEST_TAG = "cluster_command_launcher";
+
+    private ClusterCommand mCommand;
+    private TestEnvironment mTestEnvironment;
+    private List<TestResource> mTestResources;
+    private TestContext mTestContext;
+    private File mWorkDir;
+
+    /**
+     * Set a {@link ClusterCommand} object.
+     *
+     * @param command a {@link ClusterCommand} object.
+     * @return {@link ClusterCommandConfigBuilder} for chaining.
+     */
+    public ClusterCommandConfigBuilder setClusterCommand(ClusterCommand command) {
+        mCommand = command;
+        return this;
+    }
+
+    /**
+     * Set a {@link TestEnvironment} object.
+     *
+     * @param testEnvironment a {@link TestEnvironment} object.
+     * @return {@link ClusterCommandConfigBuilder} for chaining.
+     */
+    public ClusterCommandConfigBuilder setTestEnvironment(TestEnvironment testEnvironment) {
+        mTestEnvironment = testEnvironment;
+        return this;
+    }
+
+    /**
+     * Set a list of {@link TestResource} object.
+     *
+     * @param testResources a list of {@link TestResource} objects.
+     * @return {@link ClusterCommandConfigBuilder} for chaining.
+     */
+    public ClusterCommandConfigBuilder setTestResources(List<TestResource> testResources) {
+        mTestResources = testResources;
+        return this;
+    }
+
+    /**
+     * Set a {@link TestContext} object.
+     *
+     * @param testContext a {@link TestContext} object.
+     * @return {@link ClusterCommandConfigBuilder} for chaining.
+     */
+    public ClusterCommandConfigBuilder setTestContext(TestContext testContext) {
+        mTestContext = testContext;
+        return this;
+    }
+
+    /**
+     * Set a work directory for a command.
+     *
+     * @param workDir a work directory.
+     * @return {@link ClusterCommandConfigBuilder} for chaining.
+     */
+    public ClusterCommandConfigBuilder setWorkDir(File workDir) {
+        mWorkDir = workDir;
+        return this;
+    }
+
+    /** Get a {@link IConfiguration} object type name for {@link TradefedConfigObject.Type}. */
+    private String getConfigObjectTypeName(TradefedConfigObject.Type type) {
+        switch (type) {
+            case TARGET_PREPARER:
+                return Configuration.TARGET_PREPARER_TYPE_NAME;
+            case RESULT_REPORTER:
+                return Configuration.RESULT_REPORTER_TYPE_NAME;
+            default:
+                throw new UnsupportedOperationException(String.format("%s is not supported", type));
+        }
+    }
+
+    /** Create a {@link IConfiguration} object for a {@link TradefedConfigObject}. */
+    private Object createConfigObject(
+            TradefedConfigObject configObjDef, Map<String, String> envVars)
+            throws ConfigurationException {
+        Object configObj = null;
+        try {
+            configObj = Class.forName(configObjDef.getClassName()).newInstance();
+        } catch (InstantiationException | IllegalAccessException | ClassNotFoundException e) {
+            throw new ConfigurationException(
+                    String.format(
+                            "Failed to add a config object '%s'", configObjDef.getClassName()),
+                    e);
+        }
+        MultiMap<String, String> optionValues = configObjDef.getOptionValues();
+        List<String> optionArgs = new ArrayList<>();
+        for (String name : optionValues.keySet()) {
+            List<String> values = optionValues.get(name);
+            for (String value : values) {
+                optionArgs.add(String.format("--%s", name));
+                if (value != null) {
+                    // value can be null for valueless options.
+                    optionArgs.add(StringUtil.expand(value, envVars));
+                }
+            }
+        }
+        ArgsOptionParser parser = new ArgsOptionParser(configObj);
+        parser.parse(optionArgs);
+        return configObj;
+    }
+
+    /**
+     * Builds a configuration file.
+     *
+     * @return a {@link File} object for a generated configuration file.
+     */
+    public File build() throws ConfigurationException, IOException {
+        assert mCommand != null;
+        assert mTestEnvironment != null;
+        assert mTestResources != null;
+        assert mWorkDir != null;
+
+        IConfiguration config = new Configuration("Cluster Command " + mCommand.getCommandId(), "");
+        config.getCommandOptions().setTestTag(TEST_TAG);
+        List<IDeviceConfiguration> deviceConfigs = new ArrayList<>();
+        int index = 0;
+        assert 0 < mCommand.getTargetDeviceSerials().size();
+
+        // Split config defs into device/non-device ones.
+        List<TradefedConfigObject> deviceConfigObjDefs = new ArrayList<>();
+        List<TradefedConfigObject> nonDeviceConfigObjDefs = new ArrayList<>();
+        for (TradefedConfigObject configObjDef : mTestEnvironment.getTradefedConfigObjects()) {
+            if (TradefedConfigObject.Type.TARGET_PREPARER.equals(configObjDef.getType())) {
+                deviceConfigObjDefs.add(configObjDef);
+            } else {
+                nonDeviceConfigObjDefs.add(configObjDef);
+            }
+        }
+
+        Map<String, String> envVars = new TreeMap<>();
+        envVars.put("TF_WORK_DIR", mWorkDir.getAbsolutePath());
+        envVars.putAll(mTestEnvironment.getEnvVars());
+        envVars.putAll(mTestContext.getEnvVars());
+        for (String serial : mCommand.getTargetDeviceSerials()) {
+            IDeviceConfiguration device =
+                    new DeviceConfigurationHolder(String.format("TF_DEVICE_%d", index++));
+            device.getDeviceRequirements().setSerial(serial);
+            for (TradefedConfigObject configObjDef : deviceConfigObjDefs) {
+                device.addSpecificConfig(createConfigObject(configObjDef, envVars));
+            }
+            deviceConfigs.add(device);
+        }
+        deviceConfigs.get(0).addSpecificConfig(new ClusterBuildProvider());
+        config.setDeviceConfigList(deviceConfigs);
+        config.setTest(new ClusterCommandLauncher());
+        config.setLogSaver(new ClusterLogSaver());
+        // TODO(b/135636270): return log path to TFC instead of relying on a specific filename
+        config.setLogOutput(new SimpleFileLogger());
+        config.injectOptionValue(
+                "simple-file:path",
+                Paths.get(mWorkDir.getAbsolutePath(), "logs", "host_log.txt").toString());
+        config.setTestInvocationListeners(Collections.<ITestInvocationListener>emptyList());
+        for (TradefedConfigObject configObjDef : nonDeviceConfigObjDefs) {
+            String typeName = getConfigObjectTypeName(configObjDef.getType());
+            @SuppressWarnings("unchecked")
+            List<Object> configObjs = (List<Object>) config.getConfigurationObjectList(typeName);
+            configObjs.add(createConfigObject(configObjDef, envVars));
+            config.setConfigurationObjectList(typeName, configObjs);
+        }
+
+        config.injectOptionValue("cluster:request-id", mCommand.getRequestId());
+        config.injectOptionValue("cluster:command-id", mCommand.getCommandId());
+        config.injectOptionValue("cluster:attempt-id", mCommand.getAttemptId());
+        // FIXME: Make this configurable.
+        config.injectOptionValue("enable-root", "false");
+
+        String commandLine = mTestContext.getCommandLine();
+        if (commandLine == null || commandLine.isEmpty()) {
+            commandLine = mCommand.getCommandLine();
+        }
+        config.injectOptionValue("cluster:command-line", commandLine);
+        config.injectOptionValue("cluster:original-command-line", mCommand.getCommandLine());
+        config.injectOptionValue("cluster:root-dir", mWorkDir.getAbsolutePath());
+
+        for (final Map.Entry<String, String> entry : envVars.entrySet()) {
+            config.injectOptionValue("cluster:env-var", entry.getKey(), entry.getValue());
+        }
+        for (final String script : mTestEnvironment.getSetupScripts()) {
+            config.injectOptionValue("cluster:setup-script", script);
+        }
+        if (mTestEnvironment.useSubprocessReporting()) {
+            config.injectOptionValue("cluster:use-subprocess-reporting", "true");
+        }
+        config.injectOptionValue(
+                "cluster:output-idle-timeout",
+                String.valueOf(mTestEnvironment.getOutputIdleTimeout()));
+        for (final Map.Entry<String, String> entry :
+                mTestEnvironment.getJavaProperties().entrySet()) {
+            config.injectOptionValue("cluster:java-property", entry.getKey(), entry.getValue());
+        }
+        if (mTestEnvironment.getOutputFileUploadUrl() != null) {
+            String baseUrl = mTestEnvironment.getOutputFileUploadUrl();
+            if (!baseUrl.endsWith("/")) {
+                baseUrl += "/";
+            }
+            final String url =
+                    String.format(
+                            "%s%s/%s/", baseUrl, mCommand.getCommandId(), mCommand.getAttemptId());
+            config.injectOptionValue("cluster:output-file-upload-url", url);
+        }
+        for (final String pattern : mTestEnvironment.getOutputFilePatterns()) {
+            config.injectOptionValue("cluster:output-file-pattern", pattern);
+        }
+        if (mTestEnvironment.getContextFilePattern() != null) {
+            config.injectOptionValue(
+                    "cluster:context-file-pattern", mTestEnvironment.getContextFilePattern());
+        }
+        for (String file : mTestEnvironment.getExtraContextFiles()) {
+            config.injectOptionValue("cluster:extra-context-file", file);
+        }
+        if (mTestEnvironment.getRetryCommandLine() != null) {
+            config.injectOptionValue(
+                    "cluster:retry-command-line", mTestEnvironment.getRetryCommandLine());
+        }
+        if (mTestEnvironment.getLogLevel() != null) {
+            config.injectOptionValue("log-level", mTestEnvironment.getLogLevel());
+        }
+
+        List<TestResource> testResources = new ArrayList<>();
+        testResources.addAll(mTestResources);
+        testResources.addAll(mTestContext.getTestResources());
+        for (final TestResource resource : testResources) {
+            config.injectOptionValue(
+                    "cluster:test-resource", resource.getName(), resource.getUrl());
+        }
+
+        File f = new File(mWorkDir, "command.xml");
+        PrintWriter writer = new PrintWriter(f);
+        config.dumpXml(writer);
+        writer.close();
+        return f;
+    }
+}
diff --git a/src/com/android/tradefed/cluster/ClusterCommandEvent.java b/src/com/android/tradefed/cluster/ClusterCommandEvent.java
new file mode 100644
index 0000000..2e90da2
--- /dev/null
+++ b/src/com/android/tradefed/cluster/ClusterCommandEvent.java
@@ -0,0 +1,236 @@
+/*
+ * Copyright (C) 2019 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.cluster;
+
+import com.android.tradefed.log.LogUtil.CLog;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+/** A class to encapsulate cluster command events to be uploaded. */
+public class ClusterCommandEvent implements IClusterEvent {
+
+    public static final String DATA_KEY_ERROR = "error";
+    public static final String DATA_KEY_SUMMARY = "summary";
+    public static final String DATA_KEY_SETUP_TIME_MILLIS = "setup_time_millis";
+    public static final String DATA_KEY_FETCH_BUILD_TIME_MILLIS = "fetch_build_time_millis";
+    public static final String DATA_KEY_TOTAL_TEST_COUNT = "total_test_count";
+    public static final String DATA_KEY_FAILED_TEST_COUNT = "failed_test_count";
+    public static final String DATA_KEY_FAILED_TEST_RUN_COUNT = "failed_test_run_count";
+    public static final String EVENT_QUEUE = "command-event-queue";
+
+    // Maximum size of an individual data string value.
+    public static final int MAX_DATA_STRING_SIZE = 4095;
+
+    public enum Type {
+        AllocationFailed,
+        ConfigurationError,
+        FetchFailed,
+        ExecuteFailed,
+        InvocationInitiated,
+        InvocationStarted,
+        InvocationFailed,
+        InvocationEnded,
+        InvocationCompleted,
+        TestRunInProgress,
+        TestEnded
+    }
+
+    private long mTimestamp;
+    private Type mType;
+    private String mCommandTaskId;
+    private String mAttemptId;
+    private String mHostName;
+    private InvocationStatus mInvocationStatus;
+    private Map<String, Object> mData = new HashMap<>();
+    private Set<String> mDeviceSerials;
+
+    private ClusterCommandEvent() {}
+
+    public String getHostName() {
+        return mHostName;
+    }
+
+    public long getTimestamp() {
+        return mTimestamp;
+    }
+
+    public Type getType() {
+        return mType;
+    }
+
+    public String getCommandTaskId() {
+        return mCommandTaskId;
+    }
+
+    public String getAttemptId() {
+        return mAttemptId;
+    }
+
+    public InvocationStatus getInvocationStatus() {
+        return mInvocationStatus;
+    }
+
+    public Map<String, Object> getData() {
+        return mData;
+    }
+
+    public Set<String> getDeviceSerials() {
+        return mDeviceSerials;
+    }
+
+    public static class Builder {
+
+        private long mTimestamp = System.currentTimeMillis();
+        private Type mType;
+        private String mCommandTaskId;
+        private String mAttemptId;
+        private String mHostName;
+        private InvocationStatus mInvocationStatus;
+        private Map<String, Object> mData = new HashMap<>();
+        private Set<String> mDeviceSerials = new HashSet<>();
+
+        public Builder() {}
+
+        public Builder setTimestamp(final long timestamp) {
+            mTimestamp = timestamp;
+            return this;
+        }
+
+        public Builder setType(final Type type) {
+            mType = type;
+            return this;
+        }
+
+        public Builder setCommandTaskId(final String commandTaskId) {
+            mCommandTaskId = commandTaskId;
+            return this;
+        }
+
+        public Builder setAttemptId(final String attemptId) {
+            mAttemptId = attemptId;
+            return this;
+        }
+
+        public Builder setHostName(final String hostName) {
+            mHostName = hostName;
+            return this;
+        }
+
+        public Builder setInvocationStatus(final InvocationStatus invocationStatus) {
+            mInvocationStatus = invocationStatus;
+            return this;
+        }
+
+        public Builder setData(final String name, final Object value) {
+            if (value instanceof String && ((String) value).length() > MAX_DATA_STRING_SIZE) {
+                CLog.w(
+                        String.format(
+                                "Data for '%s' exceeds %d characters, and has been truncated.",
+                                name, MAX_DATA_STRING_SIZE));
+                mData.put(name, ((String) value).substring(0, MAX_DATA_STRING_SIZE));
+            } else {
+                mData.put(name, value);
+            }
+            return this;
+        }
+
+        public Builder setDeviceSerials(final Set<String> deviceSerials) {
+            mDeviceSerials = deviceSerials;
+            return this;
+        }
+
+        public Builder addDeviceSerial(final String deviceSerial) {
+            mDeviceSerials.add(deviceSerial);
+            return this;
+        }
+
+        public ClusterCommandEvent build() {
+            final ClusterCommandEvent obj = new ClusterCommandEvent();
+            obj.mTimestamp = mTimestamp;
+            obj.mType = mType;
+            obj.mCommandTaskId = mCommandTaskId;
+            obj.mAttemptId = mAttemptId;
+            obj.mHostName = mHostName;
+            obj.mInvocationStatus = mInvocationStatus;
+            obj.mData = new HashMap<>(mData);
+            obj.mDeviceSerials = mDeviceSerials;
+            return obj;
+        }
+    }
+
+    /**
+     * Creates a base {@link Builder}.
+     *
+     * @return a {@link Builder}.
+     */
+    public static Builder createEventBuilder() {
+        return createEventBuilder(null);
+    }
+
+    /**
+     * Creates a base {@link Builder} for the given {@link ClusterCommand}.
+     *
+     * @return a {@link Builder}.
+     */
+    public static Builder createEventBuilder(final ClusterCommand command) {
+        final ClusterCommandEvent.Builder builder = new ClusterCommandEvent.Builder();
+        if (command != null) {
+            builder.setCommandTaskId(command.getTaskId());
+            builder.setAttemptId(command.getAttemptId());
+        }
+        return builder;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public JSONObject toJSON() throws JSONException {
+        final JSONObject json = new JSONObject();
+        json.put("type", this.getType().toString());
+        // event time should be in POSIX timestamp.
+        json.put("time", this.getTimestamp() / 1000);
+        json.put("task_id", this.getCommandTaskId());
+        json.put("attempt_id", this.getAttemptId());
+        json.put("hostname", this.getHostName());
+        // TODO(b/79583735): deprecated.
+        if (!this.getDeviceSerials().isEmpty()) {
+            json.put("device_serial", this.getDeviceSerials().iterator().next());
+        }
+        json.put("device_serials", new JSONArray(this.getDeviceSerials()));
+        if (mInvocationStatus != null) {
+            json.put("invocation_status", mInvocationStatus.toJSON());
+        }
+        json.put("data", new JSONObject(this.getData()));
+        return json;
+    }
+
+    @Override
+    public String toString() {
+        String str = null;
+        try {
+            str = toJSON().toString();
+        } catch (final JSONException e) {
+            // ignore
+        }
+        return str;
+    }
+}
diff --git a/src/com/android/tradefed/cluster/ClusterCommandLauncher.java b/src/com/android/tradefed/cluster/ClusterCommandLauncher.java
new file mode 100644
index 0000000..a359a22
--- /dev/null
+++ b/src/com/android/tradefed/cluster/ClusterCommandLauncher.java
@@ -0,0 +1,363 @@
+/*
+ * Copyright (C) 2019 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.cluster;
+
+import com.android.annotations.VisibleForTesting;
+import com.android.helper.aoa.UsbDevice;
+import com.android.helper.aoa.UsbHelper;
+import com.android.tradefed.config.GlobalConfiguration;
+import com.android.tradefed.config.IConfiguration;
+import com.android.tradefed.config.IConfigurationReceiver;
+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.result.ITestInvocationListener;
+import com.android.tradefed.testtype.IInvocationContextReceiver;
+import com.android.tradefed.testtype.IRemoteTest;
+import com.android.tradefed.util.ArrayUtil;
+import com.android.tradefed.util.CommandResult;
+import com.android.tradefed.util.CommandStatus;
+import com.android.tradefed.util.FileIdleMonitor;
+import com.android.tradefed.util.FileUtil;
+import com.android.tradefed.util.IRunUtil;
+import com.android.tradefed.util.QuotationAwareTokenizer;
+import com.android.tradefed.util.RunUtil;
+import com.android.tradefed.util.StreamUtil;
+import com.android.tradefed.util.StringEscapeUtils;
+import com.android.tradefed.util.StringUtil;
+import com.android.tradefed.util.SubprocessTestResultsParser;
+import com.android.tradefed.util.SystemUtil;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Set;
+
+/**
+ * A {@link IRemoteTest} class to launch a command from TFC via a subprocess TF. FIXME: this needs
+ * to be extended to support multi-device tests.
+ */
+@OptionClass(alias = "cluster", global_namespace = false)
+public class ClusterCommandLauncher
+        implements IRemoteTest, IInvocationContextReceiver, IConfigurationReceiver {
+
+    public static final String TF_JAR_DIR = "TF_JAR_DIR";
+    public static final String TF_PATH = "TF_PATH";
+    public static final String TEST_WORK_DIR = "TEST_WORK_DIR";
+
+    private static final Duration MAX_EVENT_RECEIVER_WAIT_TIME = Duration.ofMinutes(10);
+
+    @Option(name = "root-dir", description = "A root directory", mandatory = true)
+    private File mRootDir;
+
+    @Option(name = "env-var", description = "Environment variables")
+    private Map<String, String> mEnvVars = new LinkedHashMap<>();
+
+    @Option(name = "setup-script", description = "Setup scripts")
+    private List<String> mSetupScripts = new ArrayList<>();
+
+    @Option(name = "script-timeout", description = "Script execution timeout", isTimeVal = true)
+    private long mScriptTimeout = 30 * 60 * 1000;
+
+    @Option(name = "java-property", description = "Java properties")
+    private Map<String, String> mJavaProperties = new LinkedHashMap<>();
+
+    @Option(name = "command-line", description = "A command line to launch.", mandatory = true)
+    private String mCommandLine = null;
+
+    @Option(
+            name = "original-command-line",
+            description =
+                    "Original command line. It may differ from command-line in retry invocations.")
+    private String mOriginalCommandLine = null;
+
+    @Option(name = "use-subprocess-reporting", description = "Use subprocess reporting.")
+    private boolean mUseSubprocessReporting = false;
+
+    @Option(
+            name = "output-idle-timeout",
+            description = "Maximum time to wait for an idle subprocess",
+            isTimeVal = true)
+    private long mOutputIdleTimeout = 0L;
+
+    private IInvocationContext mInvocationContext;
+    private IConfiguration mConfiguration;
+    private IRunUtil mRunUtil;
+
+    @Override
+    public void setInvocationContext(IInvocationContext invocationContext) {
+        mInvocationContext = invocationContext;
+    }
+
+    @Override
+    public void setConfiguration(IConfiguration configuration) {
+        mConfiguration = configuration;
+    }
+
+    private String getEnvVar(String key) {
+        return getEnvVar(key, null);
+    }
+
+    private String getEnvVar(String key, String defaultValue) {
+        String value = mEnvVars.getOrDefault(key, defaultValue);
+        if (value != null) {
+            value = StringUtil.expand(value, mEnvVars);
+        }
+        return value;
+    }
+
+    @Override
+    public void run(ITestInvocationListener listener) throws DeviceNotAvailableException {
+        // Pass jars under TF_PATH via classpath option (-cp)
+        String tfPath = getEnvVar(TF_PATH, System.getProperty(TF_JAR_DIR));
+        if (tfPath == null) {
+            throw new RuntimeException("cannot find TF path!");
+        }
+        final Set<String> jars = new LinkedHashSet<>();
+        for (final String path : tfPath.split(":")) {
+            jars.add(new File(path, "*").getAbsolutePath());
+        }
+
+        IRunUtil runUtil = getRunUtil();
+        runUtil.setWorkingDir(mRootDir);
+        // clear the TF_GLOBAL_CONFIG env, so another tradefed will not reuse the global config file
+        runUtil.unsetEnvVariable(GlobalConfiguration.GLOBAL_CONFIG_VARIABLE);
+        for (final String key : mEnvVars.keySet()) {
+            runUtil.setEnvVariable(key, getEnvVar(key));
+        }
+
+        final File testWorkDir = new File(getEnvVar(TEST_WORK_DIR, mRootDir.getAbsolutePath()));
+        final File logDir = new File(mRootDir, "logs");
+        logDir.mkdirs();
+        File stdoutFile = new File(logDir, "stdout.txt");
+        File stderrFile = new File(logDir, "stderr.txt");
+        FileIdleMonitor monitor = createFileMonitor(stdoutFile, stderrFile);
+
+        SubprocessTestResultsParser subprocessEventParser = null;
+        try (FileOutputStream stdout = new FileOutputStream(stdoutFile);
+                FileOutputStream stderr = new FileOutputStream(stderrFile)) {
+            long timeout = mScriptTimeout;
+            long startTime = System.currentTimeMillis();
+            for (String script : mSetupScripts) {
+                script = StringUtil.expand(script, mEnvVars);
+                CLog.i("Running a setup script: %s", script);
+                // FIXME: Refactor command execution into a helper function.
+                CommandResult result =
+                        runUtil.runTimedCmd(
+                                timeout,
+                                stdout,
+                                stderr,
+                                QuotationAwareTokenizer.tokenizeLine(script));
+                if (!result.getStatus().equals(CommandStatus.SUCCESS)) {
+                    String error = null;
+                    if (result.getStatus().equals(CommandStatus.TIMED_OUT)) {
+                        error = "timeout";
+                    } else {
+                        error = FileUtil.readStringFromFile(stderrFile);
+                    }
+                    throw new RuntimeException(String.format("Script failed to run: %s", error));
+                }
+                timeout -= (System.currentTimeMillis() - startTime);
+                if (timeout < 0) {
+                    throw new RuntimeException(
+                            String.format("Setup scripts failed to run in %sms", mScriptTimeout));
+                }
+            }
+
+            String classpath = ArrayUtil.join(":", jars);
+            String commandLine = mCommandLine;
+            if (classpath.isEmpty()) {
+                throw new RuntimeException(
+                        String.format("cannot find any TF jars from %s!", tfPath));
+            }
+
+            if (mOriginalCommandLine != null && !mOriginalCommandLine.equals(commandLine)) {
+                // Make sure a wrapper XML of the original command is available because retries
+                // try to run original commands in Q+. If the original command was run with
+                // subprocess reporting, a recorded command would be one with .xml suffix.
+                new SubprocessConfigBuilder()
+                        .setWorkingDir(testWorkDir)
+                        .setOriginalConfig(
+                                QuotationAwareTokenizer.tokenizeLine(mOriginalCommandLine)[0])
+                        .build();
+            }
+            if (mUseSubprocessReporting) {
+                SubprocessReportingHelper mHelper = new SubprocessReportingHelper();
+                // Create standalone jar for subprocess result reporter, which is used
+                // for pre-O cts. The created jar is put in front position of the class path to
+                // override class with the same name.
+                classpath =
+                        String.format(
+                                "%s:%s",
+                                mHelper.createSubprocessReporterJar(mRootDir).getAbsolutePath(),
+                                classpath);
+                subprocessEventParser =
+                        createSubprocessTestResultsParser(listener, true, mInvocationContext);
+                String port = Integer.toString(subprocessEventParser.getSocketServerPort());
+                commandLine = mHelper.buildNewCommandConfig(commandLine, port, testWorkDir);
+            }
+
+            List<String> javaCommandArgs = buildJavaCommandArgs(classpath, commandLine);
+            CLog.i("Running a command line: %s", commandLine);
+            CLog.i("args = %s", javaCommandArgs);
+            CLog.i("test working directory = %s", testWorkDir);
+
+            monitor.start();
+            runUtil.setWorkingDir(testWorkDir);
+            CommandResult result =
+                    runUtil.runTimedCmd(
+                            mConfiguration.getCommandOptions().getInvocationTimeout(),
+                            stdout,
+                            stderr,
+                            javaCommandArgs.toArray(new String[javaCommandArgs.size()]));
+            if (!result.getStatus().equals(CommandStatus.SUCCESS)) {
+                String error = null;
+                if (result.getStatus().equals(CommandStatus.TIMED_OUT)) {
+                    error = "timeout";
+                } else {
+                    error = FileUtil.readStringFromFile(stderrFile);
+                }
+                throw new RuntimeException(String.format("Command failed to run: %s", error));
+            }
+            CLog.i("Successfully ran a command");
+
+        } catch (IOException e) {
+            throw new RuntimeException(e);
+        } finally {
+            monitor.stop();
+            if (subprocessEventParser != null) {
+                subprocessEventParser.joinReceiver(
+                        MAX_EVENT_RECEIVER_WAIT_TIME.toMillis(), /* wait for connection */ false);
+                StreamUtil.close(subprocessEventParser);
+            }
+        }
+    }
+
+    /** Build a shell command line to invoke a TF process. */
+    private List<String> buildJavaCommandArgs(String classpath, String tfCommandLine) {
+        // Build a command line to invoke a TF process.
+        final List<String> cmdArgs = new ArrayList<>();
+        cmdArgs.add(SystemUtil.getRunningJavaBinaryPath().getAbsolutePath());
+        cmdArgs.add("-cp");
+        cmdArgs.add(classpath);
+
+        // Pass Java properties as -D options.
+        for (final Entry<String, String> entry : mJavaProperties.entrySet()) {
+            cmdArgs.add(
+                    String.format(
+                            "-D%s=%s",
+                            entry.getKey(), StringUtil.expand(entry.getValue(), mEnvVars)));
+        }
+        cmdArgs.add("com.android.tradefed.command.CommandRunner");
+        tfCommandLine = StringUtil.expand(tfCommandLine, mEnvVars);
+        cmdArgs.addAll(StringEscapeUtils.paramsToArgs(ArrayUtil.list(tfCommandLine)));
+
+        final Integer shardCount = mConfiguration.getCommandOptions().getShardCount();
+        final Integer shardIndex = mConfiguration.getCommandOptions().getShardIndex();
+        if (shardCount != null && shardCount > 1) {
+            cmdArgs.add("--shard-count");
+            cmdArgs.add(Integer.toString(shardCount));
+            if (shardIndex != null) {
+                cmdArgs.add("--shard-index");
+                cmdArgs.add(Integer.toString(shardIndex));
+            }
+        }
+
+        for (final ITestDevice device : mInvocationContext.getDevices()) {
+            // FIXME: Find a better way to support non-physical devices as well.
+            cmdArgs.add("--serial");
+            cmdArgs.add(device.getSerialNumber());
+        }
+
+        return cmdArgs;
+    }
+
+    /** Creates a file monitor which will perform a USB port reset if the subprocess is idle. */
+    private FileIdleMonitor createFileMonitor(File... files) {
+        // treat zero or negative timeout as infinite
+        long timeout = mOutputIdleTimeout > 0 ? mOutputIdleTimeout : Long.MAX_VALUE;
+        // reset USB ports if files are idle for too long
+        // TODO(peykov): consider making the callback customizable
+        return new FileIdleMonitor(Duration.ofMillis(timeout), this::resetUsbPorts, files);
+    }
+
+    /** Performs a USB port reset on all devices. */
+    private void resetUsbPorts() {
+        CLog.i("Subprocess output idle for %d ms, attempting USB port reset.", mOutputIdleTimeout);
+        try (UsbHelper usb = new UsbHelper()) {
+            for (String serial : mInvocationContext.getSerials()) {
+                try (UsbDevice device = usb.getDevice(serial)) {
+                    if (device == null) {
+                        CLog.w("Device '%s' not found during USB reset.", serial);
+                        continue;
+                    }
+                    CLog.d("Resetting USB port for device '%s'", serial);
+                    device.reset();
+                }
+            }
+        }
+    }
+
+    @VisibleForTesting
+    IRunUtil getRunUtil() {
+        if (mRunUtil == null) {
+            mRunUtil = new RunUtil();
+        }
+        return mRunUtil;
+    }
+
+    @VisibleForTesting
+    SubprocessTestResultsParser createSubprocessTestResultsParser(
+            ITestInvocationListener listener, boolean streaming, IInvocationContext context)
+            throws IOException {
+        return new SubprocessTestResultsParser(listener, streaming, context);
+    }
+
+    @VisibleForTesting
+    Map<String, String> getEnvVars() {
+        return mEnvVars;
+    }
+
+    @VisibleForTesting
+    List<String> getSetupScripts() {
+        return mSetupScripts;
+    }
+
+    @VisibleForTesting
+    Map<String, String> getJavaProperties() {
+        return mJavaProperties;
+    }
+
+    @VisibleForTesting
+    String getCommandLine() {
+        return mCommandLine;
+    }
+
+    @VisibleForTesting
+    boolean useSubprocessReporting() {
+        return mUseSubprocessReporting;
+    }
+}
diff --git a/src/com/android/tradefed/cluster/ClusterCommandScheduler.java b/src/com/android/tradefed/cluster/ClusterCommandScheduler.java
new file mode 100644
index 0000000..d21d2f9
--- /dev/null
+++ b/src/com/android/tradefed/cluster/ClusterCommandScheduler.java
@@ -0,0 +1,718 @@
+/*
+ * Copyright (C) 2019 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.cluster;
+
+import com.android.tradefed.build.BuildInfo;
+import com.android.tradefed.command.CommandScheduler;
+import com.android.tradefed.command.ICommandScheduler;
+import com.android.tradefed.command.remote.DeviceDescriptor;
+import com.android.tradefed.config.ConfigurationException;
+import com.android.tradefed.config.IConfiguration;
+import com.android.tradefed.device.DeviceAllocationState;
+import com.android.tradefed.device.DeviceNotAvailableException;
+import com.android.tradefed.device.FreeDeviceState;
+import com.android.tradefed.device.IDeviceManager;
+import com.android.tradefed.device.ITestDevice;
+import com.android.tradefed.device.NoDeviceException;
+import com.android.tradefed.device.battery.BatteryController;
+import com.android.tradefed.device.battery.IBatteryInfo;
+import com.android.tradefed.device.battery.IBatteryInfo.BatteryState;
+import com.android.tradefed.invoker.IInvocationContext;
+import com.android.tradefed.invoker.InvocationContext;
+import com.android.tradefed.log.LogUtil.CLog;
+import com.android.tradefed.result.CollectingTestListener;
+import com.android.tradefed.result.ITestSummaryListener;
+import com.android.tradefed.result.TestRunResult;
+import com.android.tradefed.result.TestSummary;
+import com.android.tradefed.util.FileUtil;
+import com.android.tradefed.util.MultiMap;
+import com.android.tradefed.util.QuotationAwareTokenizer;
+
+import com.android.tradefed.cluster.ClusterHostEvent.HostEventType;
+import com.google.common.primitives.Ints;
+
+import org.json.JSONException;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.RejectedExecutionHandler;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+import java.util.concurrent.ThreadFactory;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * A {@link ICommandScheduler} to support TFC (Tradefed Cluster). This scheduler runs commands from
+ * TFC command-queue and uploads invocation events to TFC command-event-queue.
+ */
+public class ClusterCommandScheduler extends CommandScheduler {
+
+    /** The {@link ScheduledThreadPoolExecutor} used to manage heartbeats. */
+    private ScheduledThreadPoolExecutor mHeartbeatThreadPool = null;
+
+    /** The {@link IClusterOptions} instance used to store cluster-related settings. */
+    private IClusterOptions mClusterOptions;
+
+    /** The {@link IClusterClient} instance used to interact with the TFC backend. */
+    private IClusterClient mClusterClient;
+
+    /**
+     * A {@link ThreadFactory} which returns threads in a dedicated heartbeat group.
+     *
+     * <p>This class is used as a factory by {@code mHeartbeatThreadPool} in order to segregate
+     * heartbeat threads from other "stray" threads to avoid tripping loose thread detection in
+     * {@link CommandScheduler}.
+     */
+    private static class HeartbeatThreadFactory implements ThreadFactory {
+        private static final ThreadGroup HB_GROUP;
+
+        static {
+            // fetch root thread group as this class may be initialized by an invocation thread
+            ThreadGroup tg = Thread.currentThread().getThreadGroup();
+            while (tg.getParent() != null) {
+                tg = tg.getParent();
+            }
+            HB_GROUP = new ThreadGroup(tg, "ClusterCommandScheduler.heartbeat");
+        }
+
+        @Override
+        public Thread newThread(Runnable r) {
+            Thread thread = new Thread(HB_GROUP, r);
+            // heartbeat should always get cancelled, but ensure it doesn't prevent JVM exit
+            thread.setDaemon(true);
+            return thread;
+        }
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public void start() {
+        UploadHostEventWithState(HostState.RUNNING);
+        super.start();
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public void shutdown() {
+        UploadHostEventWithState(HostState.QUITTING);
+        getHeartbeatThreadPool().shutdown();
+        super.shutdown();
+    }
+
+    @Override
+    public synchronized void shutdownHard() {
+        UploadHostEventWithState(HostState.KILLING);
+        getHeartbeatThreadPool().shutdown();
+        super.shutdownHard();
+    }
+
+    /**
+     * A {@link com.android.tradefed.command.ICommandScheduler.IScheduledInvocationListener} to
+     * upload events to TFC.
+     */
+    class InvocationEventHandler extends CollectingTestListener
+            implements IScheduledInvocationListener, ITestSummaryListener {
+
+        private ScheduledFuture<?> mHeartbeat;
+        private final ClusterCommand mCommandTask;
+        private Set<String> mDeviceSerials = new HashSet<>();
+        private String mSummary;
+        private String mError;
+        private File mWorkDir;
+        private InvocationStatus mInvocationStatus;
+
+        /**
+         * Creates a {@link InvocationEventHandler} to track the given {@link ClusterCommand}.
+         *
+         * @param commandTask the {@link ClusterCommand} to track.
+         */
+        public InvocationEventHandler(ClusterCommand commandTask) {
+            mCommandTask = commandTask;
+        }
+
+        /**
+         * Sets a work directory for an invocation.
+         *
+         * @param dir a work directory.
+         */
+        public void setWorkDir(File dir) {
+            mWorkDir = dir;
+        }
+
+        private ClusterCommandEvent.Builder createEventBuilder() {
+            final ClusterCommandEvent.Builder builder =
+                    ClusterCommandEvent.createEventBuilder(mCommandTask)
+                            .setHostName(ClusterHostUtil.getHostName());
+            if (!mDeviceSerials.isEmpty()) {
+                builder.setDeviceSerials(mDeviceSerials);
+            }
+            return builder;
+        }
+
+        private void updateInvocationStatus() {
+            if (!getClusterOptions().shouldUploadInvocationStatus()) {
+                return;
+            }
+            final InvocationStatus obj = new InvocationStatus();
+            final Collection<TestRunResult> testRunResults = this.getMergedTestRunResults();
+            for (final TestRunResult result : testRunResults) {
+                final TestGroupStatus testGroupStatus =
+                        new TestGroupStatus(
+                                result.getName(),
+                                result.getNumTests(),
+                                result.getNumCompleteTests(),
+                                result.getNumAllFailedTests(),
+                                result.isRunComplete(),
+                                result.getElapsedTime(),
+                                result.getRunFailureMessage());
+                obj.addTestGroupStatus(testGroupStatus);
+            }
+            mInvocationStatus = obj;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public void invocationInitiated(IInvocationContext context) {
+            for (ITestDevice device : context.getDevices()) {
+                mDeviceSerials.add(device.getSerialNumber());
+            }
+            final ClusterCommandEvent event =
+                    createEventBuilder()
+                            .setType(ClusterCommandEvent.Type.InvocationInitiated)
+                            .build();
+            getClusterClient().getCommandEventUploader().postEvent(event);
+            getClusterClient().getCommandEventUploader().flush();
+            mHeartbeat = startHeartbeat();
+            // Check that devices are in charging state before starting the invocation.
+            for (ITestDevice device : context.getDevices()) {
+                try {
+                    BatteryState state = BatteryController.getDeviceChargingState(device);
+                    if (BatteryState.NOT_CHARGING.equals(state)) {
+                        IBatteryInfo info = BatteryController.getBatteryInfoForDevice(device);
+                        if (info != null) {
+                            info.enableCharging(device);
+                        }
+                    }
+                } catch (DeviceNotAvailableException e) {
+                    CLog.e(e);
+                }
+            }
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public void invocationStarted(IInvocationContext context) {
+            super.invocationStarted(context);
+            final ClusterCommandEvent event =
+                    createEventBuilder()
+                            .setType(ClusterCommandEvent.Type.InvocationStarted)
+                            .build();
+            getClusterClient().getCommandEventUploader().postEvent(event);
+            getClusterClient().getCommandEventUploader().flush();
+        }
+
+        @Override
+        public void testRunStarted(String name, int numTests) {
+            testRunStarted(name, numTests, 0);
+        }
+
+        @Override
+        public void testRunStarted(String name, int numTests, int attemptNumber) {
+            testRunStarted(name, numTests, attemptNumber, System.currentTimeMillis());
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public void testRunStarted(String name, int numTests, int attemptNumber, long startTime) {
+            super.testRunStarted(name, numTests, attemptNumber, startTime);
+            updateInvocationStatus();
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public void invocationFailed(Throwable cause) {
+            super.invocationFailed(cause);
+
+            mError = cause.toString();
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public void invocationEnded(long elapsedTime) {
+            super.invocationEnded(elapsedTime);
+
+            ClusterCommandEvent event =
+                    createEventBuilder()
+                            .setType(ClusterCommandEvent.Type.InvocationEnded)
+                            .setData(ClusterCommandEvent.DATA_KEY_ERROR, mError)
+                            .build();
+            getClusterClient().getCommandEventUploader().postEvent(event);
+            getClusterClient().getCommandEventUploader().flush();
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public void invocationComplete(
+                IInvocationContext metadata, Map<ITestDevice, FreeDeviceState> devicesStates) {
+            if (mWorkDir != null) {
+                FileUtil.recursiveDelete(mWorkDir);
+            }
+
+            // TODO: handle multi-device where only one of the build could be missing.
+            if (getPrimaryBuildInfo() == null && mError == null) {
+                mError = "build not found";
+            }
+
+            long fetchBuildTimeMillis = -1;
+            long setupTimeMillis = -1;
+            if (metadata != null && metadata.getInvocationTimingMetrics() != null) {
+                for (Entry<IInvocationContext.TimingEvent, Long> entry :
+                        metadata.getInvocationTimingMetrics().entrySet()) {
+                    switch (entry.getKey()) {
+                        case FETCH_BUILD:
+                            fetchBuildTimeMillis = entry.getValue();
+                            break;
+                        case SETUP:
+                            setupTimeMillis = entry.getValue();
+                            break;
+                    }
+                }
+            }
+
+            // Stop heartbeat thread before sending InvocationCompleted event.
+            if (mHeartbeat != null) {
+                mHeartbeat.cancel(true);
+            }
+            updateInvocationStatus();
+            final ClusterCommandEvent event =
+                    createEventBuilder()
+                            .setType(ClusterCommandEvent.Type.InvocationCompleted)
+                            .setInvocationStatus(mInvocationStatus)
+                            .setData(ClusterCommandEvent.DATA_KEY_ERROR, mError)
+                            .setData(ClusterCommandEvent.DATA_KEY_SUMMARY, mSummary)
+                            .setData(
+                                    ClusterCommandEvent.DATA_KEY_FETCH_BUILD_TIME_MILLIS,
+                                    Long.toString(fetchBuildTimeMillis))
+                            .setData(
+                                    ClusterCommandEvent.DATA_KEY_SETUP_TIME_MILLIS,
+                                    Long.toString(setupTimeMillis))
+                            .setData(
+                                    ClusterCommandEvent.DATA_KEY_TOTAL_TEST_COUNT,
+                                    Integer.toString(getNumTotalTests()))
+                            .setData(
+                                    ClusterCommandEvent.DATA_KEY_FAILED_TEST_COUNT,
+                                    Integer.toString(getNumAllFailedTests()))
+                            .setData(
+                                    ClusterCommandEvent.DATA_KEY_FAILED_TEST_RUN_COUNT,
+                                    Integer.toString(getNumAllFailedTestRuns()))
+                            .build();
+            getClusterClient().getCommandEventUploader().postEvent(event);
+            getClusterClient().getCommandEventUploader().flush();
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public void putSummary(List<TestSummary> summaries) {
+            final StringBuilder sb = new StringBuilder();
+            for (final TestSummary summary : summaries) {
+                sb.append(summary.getSummary());
+                sb.append("\n");
+            }
+            mSummary = sb.toString();
+        }
+
+        private ScheduledFuture<?> startHeartbeat() {
+            return getHeartbeatThreadPool()
+                    .scheduleAtFixedRate(
+                            new HeartbeatSender(),
+                            0,
+                            getClusterOptions().getInvocationHeartbeatInterval(),
+                            TimeUnit.MILLISECONDS);
+        }
+
+        class HeartbeatSender implements Runnable {
+            @Override
+            public void run() {
+                try {
+                    // check cluster command's status
+                    if (getClusterOptions().checkCommandState()) {
+                        ClusterCommand.State status =
+                                getClusterClient()
+                                        .getCommandState(
+                                                mCommandTask.getRequestId(),
+                                                mCommandTask.getCommandId());
+                        if (ClusterCommand.State.CANCELED.equals(status)) {
+                            CLog.w(
+                                    "Cluster marked command %s %s as canceled, stopping invocation",
+                                    mCommandTask.getRequestId(), mCommandTask.getCommandId());
+                            Optional.ofNullable(getInvocationContext())
+                                    .map(IInvocationContext::getInvocationId)
+                                    .map(Ints::tryParse)
+                                    .ifPresent(ClusterCommandScheduler.this::stopInvocation);
+                        }
+                    }
+
+                    final ClusterCommandEvent event =
+                            createEventBuilder()
+                                    .setType(ClusterCommandEvent.Type.TestRunInProgress)
+                                    .setInvocationStatus(mInvocationStatus)
+                                    .build();
+                    getClusterClient().getCommandEventUploader().postEvent(event);
+                } catch (Exception e) {
+                    CLog.e("Error sending heartbeat to TFC:");
+                    CLog.e(e);
+                }
+            }
+        }
+    }
+
+    synchronized ScheduledThreadPoolExecutor getHeartbeatThreadPool() {
+        if (mHeartbeatThreadPool == null) {
+            mHeartbeatThreadPool = new ScheduledThreadPoolExecutor(1, new HeartbeatThreadFactory());
+            // instead of throwing some exception on shutdown we simply log it.
+            mHeartbeatThreadPool.setRejectedExecutionHandler(
+                    new RejectedExecutionHandler() {
+                        @Override
+                        public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
+                            CLog.w(
+                                    "Rejecting Task %s rejected from executor %s",
+                                    r.toString(), e.toString());
+                        }
+                    });
+            // continue existing heartbeats after shutdown (until invocation is complete)
+            mHeartbeatThreadPool.setContinueExistingPeriodicTasksAfterShutdownPolicy(true);
+        }
+        return mHeartbeatThreadPool;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected void processReadyCommands(IDeviceManager manager) {
+        super.processReadyCommands(manager);
+
+        if (isShuttingDown()) {
+            return;
+        }
+
+        List<ClusterCommand> commands = null;
+        MultiMap<String, DeviceDescriptor> devices = getAvailableDevices(manager);
+        if (devices.isEmpty()) {
+            CLog.d("No devices are available for testing.");
+            return;
+        }
+        // Lease command tasks through the leasehosttasks API.
+        // Here we get all devices (available or not), so TFC will analyze the device tree to
+        // decide which group is allocated and which group is available.
+        devices = getDevices(manager, false);
+        commands = fetchHostCommands(devices);
+        if (commands.isEmpty()) {
+            CLog.d("No commands available for testing.");
+            return;
+        }
+        execCommands(commands);
+    }
+
+    /**
+     * Returns a map containing available devices grouped by their types.
+     *
+     * @param manager a {@link IDeviceManager}.
+     * @return a {@link MultiMap} of String to DeviceDescriptor containing available devices.
+     */
+    MultiMap<String, DeviceDescriptor> getAvailableDevices(IDeviceManager manager) {
+        return getDevices(manager, true);
+    }
+
+    /**
+     * Returns a map containing devices grouped by their types.
+     *
+     * @param manager a {@link IDeviceManager}.
+     * @param availableOnly only return available devices or all devices.
+     * @return a {@link MultiMap} of String to DeviceDescriptor containing available devices.
+     */
+    MultiMap<String, DeviceDescriptor> getDevices(IDeviceManager manager, boolean availableOnly) {
+        // Getting available device types
+        final MultiMap<String, DeviceDescriptor> devices = new MultiMap<>();
+        for (final DeviceDescriptor device : manager.listAllDevices()) {
+            if (availableOnly && device.getState() != DeviceAllocationState.Available) {
+                continue;
+            }
+            if (ClusterHostUtil.isIpPort(device.getSerial())) {
+                // Note(b/28802876): Skipping IP:PORT serials from cluster scheduling because they
+                // behave differently from physical devices and are not fully supported by TF.
+                continue;
+            }
+            String runTargetFormat = getClusterOptions().getRunTargetFormat();
+            String runTarget =
+                    ClusterHostUtil.getRunTarget(
+                            device, runTargetFormat, getClusterOptions().getDeviceTag());
+            CLog.d("%s is available", runTarget);
+            devices.put(runTarget, device);
+        }
+        return devices;
+    }
+
+    /**
+     * Get available flashing permits.
+     *
+     * @return the number of available flashing permits.
+     */
+    private int getAvailableFlashingPermits() {
+        // By default there is no limit on available flashing permits.
+        int availableFlashingPermits = Integer.MAX_VALUE;
+        final IClusterOptions options = getClusterOptions();
+
+        boolean checkFlashingPermitsLease = options.checkFlashingPermitsOnLease();
+        if (checkFlashingPermitsLease) {
+            availableFlashingPermits = getDeviceManager().getAvailableFlashingPermits();
+            CLog.i("available flasher permits %d", availableFlashingPermits);
+        }
+        return availableFlashingPermits;
+    }
+
+    /**
+     * Fetches commands for devices from the Tradefed Cluster's leasehosttasks API.
+     *
+     * @param devices a {@link MultiMap} of String to DeviceDescriptor containing devices.
+     * @return a list of {@link ClusterCommand}s.
+     */
+    List<ClusterCommand> fetchHostCommands(final MultiMap<String, DeviceDescriptor> devices) {
+        CLog.d("fetching cluster host commands from leasehosttasks...");
+        int availableFlashingPermits = getAvailableFlashingPermits();
+
+        // Don't try to lease if there are no flasher permits available
+        if (availableFlashingPermits == 0) {
+            CLog.i("There is no available flashing permits. Not lease any additional commands.");
+            return Collections.<ClusterCommand>emptyList();
+        }
+
+        final IClusterOptions options = getClusterOptions();
+        final MultiMap<String, String> deviceGroups = options.getDeviceGroup();
+        final Map<String, String> deviceToGroup = new HashMap<>();
+        for (String group : deviceGroups.keySet()) {
+            for (String deviceSerial : deviceGroups.get(group)) {
+                deviceToGroup.put(deviceSerial, group);
+            }
+        }
+        List<ClusterDeviceInfo> deviceInfos = new LinkedList<>();
+        for (String runTarget : devices.keySet()) {
+            for (DeviceDescriptor d : devices.get(runTarget)) {
+                String groupName = deviceToGroup.getOrDefault(d.getSerial(), null);
+                ClusterDeviceInfo deviceInfo =
+                        new ClusterDeviceInfo.Builder()
+                                .setDeviceDescriptor(d)
+                                .setRunTarget(runTarget)
+                                .setGroupName(groupName)
+                                .build();
+                deviceInfos.add(deviceInfo);
+            }
+        }
+        try {
+            int count = Math.min(deviceInfos.size(), availableFlashingPermits);
+            List<ClusterCommand> commands =
+                    getClusterClient()
+                            .leaseHostCommands(
+                                    options.getClusterId(),
+                                    ClusterHostUtil.getHostName(),
+                                    deviceInfos,
+                                    options.getNextClusterIds(),
+                                    count);
+            return commands;
+        } catch (JSONException e) {
+            CLog.e(e);
+            return Collections.<ClusterCommand>emptyList();
+        }
+    }
+
+    /**
+     * Executes commands fetched from the cluster command queue.
+     *
+     * @param commands a list of {@link ClusterCommand}s fetched from the cluster command queue.
+     */
+    void execCommands(final List<ClusterCommand> commands) {
+        for (final ClusterCommand commandTask : commands) {
+            try {
+                final InvocationEventHandler handler = new InvocationEventHandler(commandTask);
+                switch (commandTask.getRequestType()) {
+                    case UNMANAGED:
+                        execClusterCommand(commandTask, handler);
+                        break;
+                    case MANAGED:
+                        execManagedClusterCommand(commandTask, handler);
+                        break;
+                    default:
+                        throw new UnsupportedOperationException();
+                }
+            } catch (NoDeviceException e) {
+                CLog.w(
+                        "no device meets requirements for cluster command [%s]; returning...",
+                        commandTask.getTaskId());
+                CLog.w(e);
+                IClusterEventUploader<ClusterCommandEvent> eventUploader =
+                        getClusterClient().getCommandEventUploader();
+                eventUploader.postEvent(
+                        ClusterCommandEvent.createEventBuilder(commandTask)
+                                .setHostName(ClusterHostUtil.getHostName())
+                                .setType(ClusterCommandEvent.Type.AllocationFailed)
+                                .build());
+                eventUploader.flush();
+            } catch (ConfigurationException | IOException | JSONException e) {
+                CLog.w("failed to execute cluster command [%s]: %s", commandTask.getTaskId(), e);
+                CLog.w(e);
+                IClusterEventUploader<ClusterCommandEvent> eventUploader =
+                        getClusterClient().getCommandEventUploader();
+                eventUploader.postEvent(
+                        ClusterCommandEvent.createEventBuilder(commandTask)
+                                .setHostName(ClusterHostUtil.getHostName())
+                                .setType(ClusterCommandEvent.Type.ConfigurationError)
+                                .setData(ClusterCommandEvent.DATA_KEY_ERROR, e.toString())
+                                .build());
+                eventUploader.flush();
+            }
+        }
+    }
+
+    void execClusterCommand(ClusterCommand commandTask, InvocationEventHandler handler)
+            throws ConfigurationException, IllegalArgumentException, NoDeviceException {
+        String cmdLine = commandTask.getCommandLine();
+        String[] args = QuotationAwareTokenizer.tokenizeLine(cmdLine);
+        // If it is a dry run command skip execution.
+        if (dryRunCommand(handler, args)) {
+            return;
+        }
+        // Append device serials to command.
+        // By assigning all applicable serials, TF will try one by one until allocation
+        // succeeds (or fails for all). This mitigates the issue where a single bad
+        // device can starve tests.
+        if (commandTask.getTargetDeviceSerials() != null) {
+            for (String serial : commandTask.getTargetDeviceSerials()) {
+                cmdLine += " --serial ";
+                cmdLine += serial;
+            }
+        }
+        CLog.i("executing cluster command: [%s] %s", commandTask.getTaskId(), cmdLine);
+        execCommand(handler, QuotationAwareTokenizer.tokenizeLine(cmdLine));
+    }
+
+    void execManagedClusterCommand(ClusterCommand commandTask, InvocationEventHandler handler)
+            throws IOException, JSONException, ConfigurationException, NoDeviceException {
+        File workDir = null;
+        try {
+            workDir = new File(System.getProperty("java.io.tmpdir"), commandTask.getAttemptId());
+            workDir.mkdirs();
+            final String requestId = commandTask.getRequestId();
+            final String commandId = commandTask.getCommandId();
+            final IClusterClient client = getClusterClient();
+            final TestEnvironment testEnvironment = client.getTestEnvironment(requestId);
+            final List<TestResource> testResources = client.getTestResources(requestId);
+            final TestContext testContext = client.getTestContext(requestId, commandId);
+            testResources.addAll(testContext.getTestResources());
+            final File configFile =
+                    new ClusterCommandConfigBuilder()
+                            .setWorkDir(workDir)
+                            .setClusterCommand(commandTask)
+                            .setTestEnvironment(testEnvironment)
+                            .setTestResources(testResources)
+                            .setTestContext(testContext)
+                            .build();
+            CLog.i("executing cluster command: [%s] %s", commandTask.getTaskId(), configFile);
+            CLog.d("configFile: %s", FileUtil.readStringFromFile(configFile));
+            // FIXME: Find a way to upload a config file after an invocation is completed for
+            // debugging.
+            handler.setWorkDir(workDir);
+            execCommand(handler, new String[] {configFile.getAbsolutePath()});
+            // Unset workDir to avoid being cleaned up
+            workDir = null;
+        } finally {
+            if (workDir != null) {
+                FileUtil.recursiveDelete(workDir);
+            }
+        }
+    }
+
+    /**
+     * Determines if a given command is a dry-run. If the command is a dry-run, validate it. If
+     * there are any configs issue, it will throw a ConfigurationException.
+     *
+     * @param handler {@link InvocationEventHandler} to report events for dry-run validation.
+     * @param args the command to validate.
+     * @return true if the command are a dry run, false otherwise.
+     * @throws ConfigurationException
+     */
+    protected boolean dryRunCommand(final InvocationEventHandler handler, String[] args)
+            throws ConfigurationException {
+        IConfiguration config =
+                getConfigFactory().createConfigurationFromArgs(args, null, getKeyStoreClient());
+        if (config.getCommandOptions().isDryRunMode()) {
+            IInvocationContext context = new InvocationContext();
+            context.addDeviceBuildInfo("stub", new BuildInfo());
+            handler.invocationStarted(context);
+            config.validateOptions();
+            handler.invocationEnded(0);
+            IInvocationContext nullMeta = null;
+            handler.invocationComplete(nullMeta, null);
+            return true;
+        }
+        return false;
+    }
+
+    /** Get the {@link IClusterOptions} instance used to store cluster-related settings. */
+    IClusterOptions getClusterOptions() {
+        if (mClusterOptions == null) {
+            mClusterOptions = ClusterHostUtil.getClusterOptions();
+        }
+        return mClusterOptions;
+    }
+
+    /** Get the {@link IClusterClient} instance used to interact with the TFC backend. */
+    IClusterClient getClusterClient() {
+        if (mClusterClient == null) {
+            mClusterClient = ClusterHostUtil.getClusterClient();
+        }
+        return mClusterClient;
+    }
+
+    /** Event triggered, to upload host states */
+    private void UploadHostEventWithState(HostState state) {
+        try {
+            IClusterEventUploader<ClusterHostEvent> Uploader =
+                    getClusterClient().getHostEventUploader();
+            ClusterHostEvent.Builder builder =
+                    new ClusterHostEvent.Builder()
+                            .setClusterId(getClusterOptions().getClusterId())
+                            .setHostEventType(HostEventType.HostStateChanged)
+                            .setHostName(ClusterHostUtil.getHostName())
+                            .setHostState(state);
+            CLog.d("event uploading with state %s", state.toString());
+            ClusterHostEvent event = builder.build();
+            Uploader.postEvent(event);
+            CLog.d("event %s uploaded with state %s", event.toString(), state.toString());
+            Uploader.flush();
+        } catch (RuntimeException e) {
+            CLog.e("failed to upload host state %s to TFC: %s", state.toString(), e);
+        }
+    }
+}
diff --git a/src/com/android/tradefed/cluster/ClusterDeviceInfo.java b/src/com/android/tradefed/cluster/ClusterDeviceInfo.java
new file mode 100644
index 0000000..23c39eb
--- /dev/null
+++ b/src/com/android/tradefed/cluster/ClusterDeviceInfo.java
@@ -0,0 +1,99 @@
+/*
+ * Copyright (C) 2019 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.cluster;
+
+import com.android.tradefed.command.remote.DeviceDescriptor;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+/** A class to encapsulate cluster device info to be uploaded. */
+public class ClusterDeviceInfo {
+    private String mRunTarget;
+    private String mGroupName;
+    private DeviceDescriptor mDeviceDescriptor;
+
+    private ClusterDeviceInfo(
+            DeviceDescriptor deviceDescriptor, String runTarget, String groupName) {
+        mDeviceDescriptor = deviceDescriptor;
+        mRunTarget = runTarget;
+        mGroupName = groupName;
+    }
+
+    public String getRunTarget() {
+        return mRunTarget;
+    }
+
+    public String getGroupName() {
+        return mGroupName;
+    }
+
+    public DeviceDescriptor getDeviceDescriptor() {
+        return mDeviceDescriptor;
+    }
+
+    public static class Builder {
+        private DeviceDescriptor mDeviceDescriptor;
+        private String mRunTarget;
+        private String mGroupName;
+
+        public Builder() {}
+
+        public Builder setRunTarget(final String runTarget) {
+            mRunTarget = runTarget;
+            return this;
+        }
+
+        public Builder setGroupName(final String groupName) {
+            mGroupName = groupName;
+            return this;
+        }
+
+        public Builder setDeviceDescriptor(final DeviceDescriptor deviceDescriptor) {
+            mDeviceDescriptor = deviceDescriptor;
+            return this;
+        }
+
+        public ClusterDeviceInfo build() {
+            final ClusterDeviceInfo deviceInfo =
+                    new ClusterDeviceInfo(mDeviceDescriptor, mRunTarget, mGroupName);
+            return deviceInfo;
+        }
+    }
+
+    /**
+     * Generates the JSON Object for this device info.
+     *
+     * @return JSONObject equivalent of this device info.
+     * @throws JSONException
+     */
+    public JSONObject toJSON() throws JSONException {
+        final JSONObject json = new JSONObject();
+        json.put("device_serial", mDeviceDescriptor.getSerial());
+        json.put("run_target", mRunTarget);
+        json.put("build_id", mDeviceDescriptor.getBuildId());
+        json.put("product", mDeviceDescriptor.getProduct());
+        json.put("product_variant", mDeviceDescriptor.getProductVariant());
+        json.put("sdk_version", mDeviceDescriptor.getSdkVersion());
+        json.put("battery_level", mDeviceDescriptor.getBatteryLevel());
+        json.put("mac_address", mDeviceDescriptor.getMacAddress());
+        json.put("sim_state", mDeviceDescriptor.getSimState());
+        json.put("sim_operator", mDeviceDescriptor.getSimOperator());
+        json.put("state", mDeviceDescriptor.getState());
+        json.put("group_name", mGroupName);
+        return json;
+    }
+}
diff --git a/src/com/android/tradefed/cluster/ClusterDeviceMonitor.java b/src/com/android/tradefed/cluster/ClusterDeviceMonitor.java
new file mode 100644
index 0000000..fc2ed9c
--- /dev/null
+++ b/src/com/android/tradefed/cluster/ClusterDeviceMonitor.java
@@ -0,0 +1,239 @@
+/*
+ * Copyright (C) 2019 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.cluster;
+
+import com.google.common.annotations.VisibleForTesting;
+
+import com.android.tradefed.command.remote.DeviceDescriptor;
+import com.android.tradefed.config.Option;
+import com.android.tradefed.config.OptionClass;
+import com.android.tradefed.device.DeviceAllocationState;
+import com.android.tradefed.device.IDeviceMonitor;
+import com.android.tradefed.log.LogUtil.CLog;
+import com.android.tradefed.util.CommandResult;
+import com.android.tradefed.util.CommandStatus;
+import com.android.tradefed.util.IRunUtil;
+import com.android.tradefed.util.QuotationAwareTokenizer;
+import com.android.tradefed.util.RunUtil;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * An {@link IDeviceMonitor} implementation that reports results to the Tradefed Cluster service.
+ */
+@OptionClass(alias = "cluster-device-monitor")
+public class ClusterDeviceMonitor implements IDeviceMonitor {
+
+    @Option(
+            name = "host-info-cmd",
+            description =
+                    "A label and command to run periodically, to "
+                            + "collect and send host info to the backend. May be repeated. Commands containing "
+                            + "spaces should be double-quoted.")
+    private Map<String, String> mHostInfoCmds = new HashMap<String, String>();
+
+    private Map<String, String[]> mTokenizedHostInfoCmds = null;
+
+    @Option(
+            name = "host-info-cmd-timeout",
+            description =
+                    "How long to wait for each "
+                            + "host-info-cmd to complete, in millis. If the command times out, a (null) value "
+                            + "will be passed to the backend for that particular command.")
+    private long mHostInfoCmdTimeout = 5 * 1000;
+
+    /** Worker thread to dispatch a cluster host event that includes a snapshot of the devices */
+    class EventDispatcher extends Thread {
+
+        private boolean mIsCanceled = false;
+        private IClusterEventUploader<ClusterHostEvent> mEventUploader = null;
+        private IClusterOptions mClusterOptions = null;
+
+        public EventDispatcher() {
+            super("ClusterDeviceMonitor.EventDispatcher");
+            this.setDaemon(true);
+        }
+
+        @Override
+        public void run() {
+            try {
+                while (!mIsCanceled) {
+                    dispatch();
+                    getRunUtil()
+                            .sleep(
+                                    ClusterHostUtil.getClusterOptions()
+                                            .getDeviceMonitorSnapshotInterval());
+                }
+            } catch (Exception e) {
+                CLog.e(e);
+            }
+        }
+
+        IClusterEventUploader<ClusterHostEvent> getEventUploader() {
+            if (mEventUploader == null) {
+                mEventUploader = ClusterHostUtil.getClusterClient().getHostEventUploader();
+            }
+            return mEventUploader;
+        }
+
+        IClusterOptions getClusterOptions() {
+            if (mClusterOptions == null) {
+                mClusterOptions = ClusterHostUtil.getClusterOptions();
+            }
+            return mClusterOptions;
+        }
+
+        void dispatch() {
+            CLog.d("Start device snapshot.");
+            final IClusterEventUploader<ClusterHostEvent> eventUploader = getEventUploader();
+            final List<DeviceDescriptor> devices = listDevices();
+            final ClusterHostEvent.Builder builder =
+                    new ClusterHostEvent.Builder()
+                            .setHostEventType(ClusterHostEvent.HostEventType.DeviceSnapshot)
+                            .setHostName(ClusterHostUtil.getHostName())
+                            .setTfVersion(ClusterHostUtil.getTfVersion())
+                            .setData(getAdditionalHostInfo())
+                            .setClusterId(getClusterOptions().getClusterId())
+                            .setNextClusterIds(getClusterOptions().getNextClusterIds());
+            for (DeviceDescriptor device : devices) {
+                if (device.isTemporary()) {
+                    // Do not report temporary devices, they will go away when the invocation
+                    // finish.
+                    continue;
+                }
+                final ClusterDeviceInfo.Builder deviceBuilder = new ClusterDeviceInfo.Builder();
+                String runTargetFormat = getClusterOptions().getRunTargetFormat();
+                deviceBuilder.setDeviceDescriptor(device);
+                deviceBuilder.setRunTarget(
+                        ClusterHostUtil.getRunTarget(
+                                device, runTargetFormat, getClusterOptions().getDeviceTag()));
+
+                builder.addDeviceInfo(deviceBuilder.build());
+            }
+            // We want to force an upload.
+            CLog.d("Dispatched devicesnapshot.");
+            eventUploader.postEvent(builder.build());
+            eventUploader.flush();
+        }
+
+        void cancel() {
+            mIsCanceled = true;
+        }
+
+        boolean isCanceled() {
+            return mIsCanceled;
+        }
+    }
+
+    private EventDispatcher mDispatcher;
+    private DeviceLister mDeviceLister;
+
+    /** {@inheritDoc} */
+    @Override
+    public void run() {
+        if (ClusterHostUtil.getClusterOptions().isDeviceMonitorDisabled()) {
+            return;
+        }
+        mDispatcher = getEventDispatcher();
+        mDispatcher.start();
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public void stop() {
+        if (mDispatcher != null && mDispatcher.isAlive()) {
+            mDispatcher.cancel();
+        }
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public void setDeviceLister(DeviceLister lister) {
+        if (lister == null) {
+            throw new NullPointerException();
+        }
+        mDeviceLister = lister;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public void notifyDeviceStateChange(
+            String serial, DeviceAllocationState oldState, DeviceAllocationState newState) {
+        // Nothing happens. We only take snapshots. Maybe we can add state change in the future.
+    }
+
+    @VisibleForTesting
+    EventDispatcher getEventDispatcher() {
+        if (mDispatcher == null) {
+            mDispatcher = new EventDispatcher();
+        }
+        return mDispatcher;
+    }
+
+    @VisibleForTesting
+    List<DeviceDescriptor> listDevices() {
+        return mDeviceLister.listDevices();
+    }
+
+    @VisibleForTesting
+    IRunUtil getRunUtil() {
+        return RunUtil.getDefault();
+    }
+
+    /** A helper method to tokenize the host info commands. */
+    void tokenizeCommands() {
+        if (mTokenizedHostInfoCmds != null && !mTokenizedHostInfoCmds.isEmpty()) {
+            // Commands already tokenized and cached. No need to tokenize again.
+            return;
+        }
+
+        mTokenizedHostInfoCmds = new HashMap<String, String[]>(mHostInfoCmds.size());
+        // Tokenize the commands and cache the result
+        for (Map.Entry<String, String> entry : mHostInfoCmds.entrySet()) {
+            final String key = entry.getKey();
+            final String cmd = entry.getValue();
+
+            CLog.d("Tokenized key %s command: %s", key, cmd);
+            mTokenizedHostInfoCmds.put(key, QuotationAwareTokenizer.tokenizeLine(cmd));
+        }
+    }
+
+    /** Queries additional host info from host-info-cmd options in TF configs. */
+    Map<String, String> getAdditionalHostInfo() {
+        final Map<String, String> info = new HashMap<>();
+        this.tokenizeCommands();
+
+        for (Map.Entry<String, String[]> entry : mTokenizedHostInfoCmds.entrySet()) {
+            final String key = entry.getKey();
+            final String[] cmd = entry.getValue();
+            final String cmdString = mHostInfoCmds.get(key);
+
+            final CommandResult result = getRunUtil().runTimedCmdSilently(mHostInfoCmdTimeout, cmd);
+
+            CLog.d("Command %s result: %s", cmdString, result.getStatus().toString());
+
+            if (result.getStatus() == CommandStatus.SUCCESS) {
+                info.put(key, result.getStdout());
+            } else {
+                info.put(key, result.getStderr());
+            }
+        }
+
+        return info;
+    }
+}
diff --git a/src/com/android/tradefed/cluster/ClusterEventUploader.java b/src/com/android/tradefed/cluster/ClusterEventUploader.java
new file mode 100644
index 0000000..d239a9c
--- /dev/null
+++ b/src/com/android/tradefed/cluster/ClusterEventUploader.java
@@ -0,0 +1,119 @@
+/*
+ * Copyright (C) 2019 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.cluster;
+
+import com.android.tradefed.log.LogUtil.CLog;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Queue;
+import java.util.concurrent.ConcurrentLinkedQueue;
+
+/** ClusterEventUploader class, which uploads {@link IClusterEvent} to TFC. */
+public abstract class ClusterEventUploader<T extends IClusterEvent>
+        implements IClusterEventUploader<T> {
+
+    // Default maximum event batch size
+    private static final int DEFAULT_MAX_BATCH_SIZE = 200;
+
+    // Default event upload interval in ms.
+    private static final long DEFAULT_EVENT_UPLOAD_INTERVAL = 60 * 1000;
+
+    private int mMaxBatchSize = DEFAULT_MAX_BATCH_SIZE;
+    private long mEventUploadInterval = DEFAULT_EVENT_UPLOAD_INTERVAL;
+    private long mLastEventUploadTime = 0;
+    private Queue<T> mEventQueue = new ConcurrentLinkedQueue<T>();
+
+    /** {@inheritDoc} */
+    @Override
+    public void setMaxBatchSize(int batchSize) {
+        mMaxBatchSize = batchSize;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public int getMaxBatchSize() {
+        return mMaxBatchSize;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public void setEventUploadInterval(long interval) {
+        mEventUploadInterval = interval;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public long getEventUploadInterval() {
+        return mEventUploadInterval;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public void postEvent(final T event) {
+        mEventQueue.add(event);
+        uploadEvents(false);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public void flush() {
+        uploadEvents(true);
+    }
+
+    /**
+     * Upload events.
+     *
+     * @param uploadNow upload now or wait for uploading with other events.
+     */
+    private void uploadEvents(final boolean uploadNow) {
+        final long now = System.currentTimeMillis();
+        if (!uploadNow && now - mLastEventUploadTime < getEventUploadInterval()) {
+            return;
+        }
+        uploadEvents();
+    }
+
+    /** Synchronized actually upload events. Only one thread will upload the events. */
+    private synchronized void uploadEvents() {
+        // Tradefed Cluster is unable to process command events larger than 100 KiB (b/72104215).
+        // The ClusterCommandEvent Builder provides some protection against this by limiting data
+        // fields to 1 KiB, but the number of data fields is not bounded, so we're not entirely
+        // protected against this.
+
+        mLastEventUploadTime = System.currentTimeMillis();
+        List<T> events = new ArrayList<T>();
+        try {
+            // Upload batches of events until there are no more left
+            while (!mEventQueue.isEmpty()) {
+                // Limit the number of events to upload at once
+                int batchSize = getMaxBatchSize();
+                while (!mEventQueue.isEmpty() && events.size() < batchSize) {
+                    events.add(mEventQueue.poll());
+                }
+                doUploadEvents(events);
+                events.clear();
+            }
+        } catch (IOException e) {
+            CLog.w("failed to upload events: %s", e);
+            CLog.w("events will be uploaded with the next event.");
+            mEventQueue.addAll(events);
+        }
+    }
+
+    protected abstract void doUploadEvents(List<T> events) throws IOException;
+}
diff --git a/src/com/android/tradefed/cluster/ClusterHostEvent.java b/src/com/android/tradefed/cluster/ClusterHostEvent.java
new file mode 100644
index 0000000..4aa8ac9
--- /dev/null
+++ b/src/com/android/tradefed/cluster/ClusterHostEvent.java
@@ -0,0 +1,215 @@
+/*
+ * Copyright (C) 2019 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.cluster;
+
+import com.android.tradefed.command.CommandScheduler;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/** A class to encapsulate cluster host events to be uploaded. */
+public class ClusterHostEvent implements IClusterEvent {
+    private long mTimestamp;
+    private String mHostName;
+    private String mTfVersion;
+    private String mClusterId;
+    private List<String> mNextClusterIds;
+    private List<ClusterDeviceInfo> mDeviceInfos = new ArrayList<>();
+    private Map<String, String> mData = new HashMap<>();
+    public static final String EVENT_QUEUE = "host-event-queue";
+
+    /** Enums of the different types of host events. */
+    public enum HostEventType {
+        DeviceSnapshot("DeviceSnapshot", "DEVICE_SNAPSHOT"),
+        HostStateChanged("HostStateChanged", "HOST_STATE_CHANGED");
+        private final String mName;
+        // The AndroidEngProdAPIName should map to
+        // https://cs.corp.google.com/piper///depot/google3/google/internal/android/engprod/proto/test/v1/androidtest.proto?rcl=221372995&l=80
+        private final String mAndroidEngProdAPIName;
+
+        private HostEventType(final String name, final String androidEngProdAPIName) {
+            mName = name;
+            mAndroidEngProdAPIName = androidEngProdAPIName;
+        }
+
+        @Override
+        public String toString() {
+            return mName;
+        }
+
+        public String getAndroidEngProdAPIName() {
+            return mAndroidEngProdAPIName;
+        }
+    }
+
+    private HostEventType mType;
+    private CommandScheduler.HostState mHostState = CommandScheduler.HostState.UNKNOWN;
+
+    private ClusterHostEvent() {}
+
+    public long getTimestamp() {
+        return mTimestamp;
+    }
+
+    public String getHostName() {
+        return mHostName;
+    }
+
+    public String getTfVersion() {
+        return mTfVersion;
+    }
+
+    public String getClusterId() {
+        return mClusterId;
+    }
+
+    public CommandScheduler.HostState getHostState() {
+        return mHostState;
+    }
+
+    public long getTfStartTime() {
+        return ClusterHostUtil.getTfStartTimeMillis();
+    }
+
+    public List<ClusterDeviceInfo> getDeviceInfos() {
+        return mDeviceInfos;
+    }
+
+    public Map<String, String> getData() {
+        return mData;
+    }
+
+    public HostEventType getType() {
+        return mType;
+    }
+
+    public List<String> getNextClusterIds() {
+        return mNextClusterIds;
+    }
+
+    public static class Builder {
+        private HostEventType mType;
+        private long mTimestamp = System.currentTimeMillis();
+        private String mHostName;
+        private String mTfVersion;
+        private String mClusterId;
+        private List<String> mNextClusterIds;
+        private List<ClusterDeviceInfo> mDeviceInfos = new ArrayList<ClusterDeviceInfo>();
+        private Map<String, String> mData = new HashMap<>();
+        private CommandScheduler.HostState mHostState = CommandScheduler.HostState.UNKNOWN;
+
+        public Builder() {}
+
+        public Builder setHostEventType(final HostEventType type) {
+            mType = type;
+            return this;
+        }
+
+        public Builder setTimestamp(final long timestamp) {
+            mTimestamp = timestamp;
+            return this;
+        }
+
+        public Builder setClusterId(final String clusterId) {
+            mClusterId = clusterId;
+            return this;
+        }
+
+        public Builder setHostName(final String hostname) {
+            mHostName = hostname;
+            return this;
+        }
+
+        public Builder setTfVersion(final String tfVersion) {
+            mTfVersion = tfVersion;
+            return this;
+        }
+
+        public Builder addDeviceInfo(final ClusterDeviceInfo deviceInfo) {
+            mDeviceInfos.add(deviceInfo);
+            return this;
+        }
+
+        public Builder addDeviceInfos(List<ClusterDeviceInfo> deviceInfos) {
+            mDeviceInfos.addAll(deviceInfos);
+            return this;
+        }
+
+        public Builder setData(final String name, final String value) {
+            mData.put(name, value);
+            return this;
+        }
+
+        public Builder setData(Map<String, String> data) {
+            mData.putAll(data);
+            return this;
+        }
+
+        public Builder setNextClusterIds(List<String> nexClusterIds) {
+            mNextClusterIds = nexClusterIds;
+            return this;
+        }
+
+        public Builder setHostState(CommandScheduler.HostState state) {
+            mHostState = state;
+            return this;
+        }
+
+        public ClusterHostEvent build() {
+            final ClusterHostEvent event = new ClusterHostEvent();
+            event.mType = mType;
+            event.mTimestamp = mTimestamp;
+            event.mHostName = mHostName;
+            event.mTfVersion = mTfVersion;
+            event.mClusterId = mClusterId;
+            event.mDeviceInfos = new ArrayList<>(mDeviceInfos);
+            event.mData = new HashMap<>(mData);
+            event.mNextClusterIds = mNextClusterIds;
+            event.mHostState = mHostState;
+            return event;
+        }
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public JSONObject toJSON() throws JSONException {
+        final JSONObject json = new JSONObject();
+        // event time should be in POSIX timestamp.
+        json.put("time", this.getTimestamp() / 1000);
+        if (this.getType() != null) json.put("type", this.getType().toString());
+        json.put("hostname", this.getHostName());
+        json.put("tf_version", this.getTfVersion());
+        json.put("cluster", this.getClusterId());
+        JSONArray deviceInfos = new JSONArray();
+        for (ClusterDeviceInfo d : this.getDeviceInfos()) {
+            deviceInfos.put(d.toJSON());
+        }
+        json.put("device_infos", deviceInfos);
+        json.put("data", new JSONObject(this.getData()));
+        if (this.getNextClusterIds() != null) {
+            json.put("next_cluster_ids", new JSONArray(this.getNextClusterIds()));
+        }
+        json.put("state", this.getHostState().toString());
+        json.put("tf_start_time_seconds", this.getTfStartTime() / 1000);
+        return json;
+    }
+}
diff --git a/src/com/android/tradefed/cluster/ClusterHostUtil.java b/src/com/android/tradefed/cluster/ClusterHostUtil.java
new file mode 100644
index 0000000..1755c44
--- /dev/null
+++ b/src/com/android/tradefed/cluster/ClusterHostUtil.java
@@ -0,0 +1,228 @@
+/*
+ * Copyright (C) 2019 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.cluster;
+
+import com.android.tradefed.command.remote.DeviceDescriptor;
+import com.android.tradefed.config.GlobalConfiguration;
+import com.android.tradefed.device.DeviceManager;
+import com.android.tradefed.device.DeviceManager.FastbootDevice;
+import com.android.tradefed.log.LogUtil.CLog;
+import com.android.tradefed.util.VersionParser;
+
+import com.google.common.base.Strings;
+import com.google.common.net.HostAndPort;
+import com.google.common.net.InetAddresses;
+import com.google.common.primitives.Longs;
+
+import java.net.InetAddress;
+import java.net.UnknownHostException;
+import java.security.InvalidParameterException;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/** Static util functions for TF Cluster to get global config instances, host information, etc. */
+public class ClusterHostUtil {
+
+    private static String sHostName = null;
+
+    static final String DEFAULT_TF_VERSION = "(unknown)";
+
+    private static long sTfStartTime = getCurrentTimeMillis();
+
+    /**
+     * Gets the hostname.
+     *
+     * @return the hostname or null if we were unable to fetch it
+     */
+    public static String getHostName() {
+        if (sHostName == null) {
+            try {
+                sHostName = InetAddress.getLocalHost().getHostName();
+            } catch (UnknownHostException e) {
+                CLog.w("failed to get hostname: %s", e);
+            }
+        }
+        return sHostName;
+    }
+
+    /**
+     * Gets the TF version running on this host.
+     *
+     * @return this host's TF version.
+     */
+    public static String getTfVersion() {
+        final String version = VersionParser.fetchVersion();
+        return toValidTfVersion(version);
+    }
+
+    /**
+     * Validates a TF version and returns it if it is OK.
+     *
+     * @param version The string for a TF version provided by {@link VersionParser}
+     * @return the version if valid or a default if not.
+     */
+    protected static String toValidTfVersion(String version) {
+        if (Strings.isNullOrEmpty(version) || Longs.tryParse(version) == null) {
+            // Making sure the version is valid. It should be a build number
+            return DEFAULT_TF_VERSION;
+        }
+        return version;
+    }
+
+    /**
+     * Returns the run target for a given device descriptor.
+     *
+     * @param device {@link DeviceDescriptor} to get run target for.
+     * @return run target.
+     */
+    public static String getRunTarget(
+            DeviceDescriptor device, String runTargetFormat, Map<String, String> deviceTags) {
+        if (runTargetFormat != null) {
+            // Make sure the pattern is non-greedy.
+            Pattern p = Pattern.compile("\\{([^:\\}]+)(:.*)?\\}");
+            Matcher m = p.matcher(runTargetFormat);
+            StringBuffer sb = new StringBuffer();
+            while (m.find()) {
+                String pattern = m.group(1);
+                String key = null;
+                String txt = null;
+                switch (pattern) {
+                    case "PRODUCT":
+                        txt = device.getProduct();
+                        break;
+                    case "PRODUCT_OR_DEVICE_CLASS":
+                        // TODO: Refactor the logic to handle more flexible combinations.
+                        txt = device.getProduct();
+                        if (device.isStubDevice()) {
+                            String deviceClass = device.getDeviceClass();
+                            // If it's a fastboot device we report it as the product
+                            if (!FastbootDevice.class.getSimpleName().equals(deviceClass)) {
+                                txt = deviceClass;
+                            }
+                        }
+                        break;
+                    case "PRODUCT_VARIANT":
+                        txt = device.getProductVariant();
+                        break;
+                    case "API_LEVEL":
+                        txt = device.getSdkVersion();
+                        break;
+                    case "DEVICE_CLASS":
+                        txt = device.getDeviceClass();
+                        break;
+                    case "SERIAL":
+                        txt = device.getSerial();
+                        break;
+                    case "TAG":
+                        if (deviceTags == null || deviceTags.isEmpty()) {
+                            // simply delete the placeholder if there's nothing to match
+                            txt = "";
+                        } else {
+                            txt = deviceTags.get(device.getSerial());
+                            if (txt == null) {
+                                txt = ""; // simply delete it if a tag does not exist
+                            }
+                        }
+                        break;
+                    case "DEVICE_PROP":
+                        key = m.group(2).substring(1);
+                        txt = device.getProperty(key);
+                        break;
+                    default:
+                        throw new InvalidParameterException(
+                                String.format(
+                                        "Unsupported pattern '%s' found for run target '%s'",
+                                        pattern, runTargetFormat));
+                }
+                if (txt == null || DeviceManager.UNKNOWN_DISPLAY_STRING.equals(txt)) {
+                    CLog.i(
+                            "No value found for pattern %s while formatting run target %s.",
+                            pattern, runTargetFormat);
+                    return DeviceManager.UNKNOWN_DISPLAY_STRING;
+                }
+                m.appendReplacement(sb, Matcher.quoteReplacement(txt));
+            }
+            m.appendTail(sb);
+            return sb.toString();
+        }
+        // Default behavior.
+        // TODO: Remove this when we cluster default run target is changed.
+        String runTarget = device.getProduct();
+        if (!runTarget.equals(device.getProductVariant())) {
+            runTarget += ":" + device.getProductVariant();
+        }
+        return runTarget;
+    }
+
+    /**
+     * Checks if a given input is a valid IP:PORT string.
+     *
+     * @param input a string to check
+     * @return true if the given input is an IP:PORT string
+     */
+    public static boolean isIpPort(String input) {
+        try {
+            HostAndPort hostAndPort = HostAndPort.fromString(input);
+            return InetAddresses.isInetAddress(hostAndPort.getHost());
+        } catch (IllegalArgumentException e) {
+            return false;
+        }
+    }
+
+    /**
+     * Returns the current system time.
+     *
+     * @return time in millis.
+     */
+    public static long getCurrentTimeMillis() {
+        return System.currentTimeMillis();
+    }
+
+    public static long getTfStartTimeMillis() {
+        return sTfStartTime;
+    }
+
+    /** Get the {@link IClusterOptions} instance used to store cluster-related settings. */
+    public static IClusterOptions getClusterOptions() {
+        IClusterOptions clusterOptions =
+                (IClusterOptions)
+                        GlobalConfiguration.getInstance()
+                                .getConfigurationObject(ClusterOptions.TYPE_NAME);
+        if (clusterOptions == null) {
+            throw new IllegalStateException(
+                    "cluster_options not defined. You must add this "
+                            + "object to your global config. See google/atp/cluster.xml.");
+        }
+
+        return clusterOptions;
+    }
+
+    /** Get the {@link IClusterClient} instance used to interact with the TFC backend. */
+    public static IClusterClient getClusterClient() {
+        IClusterClient ClusterClient =
+                (IClusterClient)
+                        GlobalConfiguration.getInstance()
+                                .getConfigurationObject(IClusterClient.TYPE_NAME);
+        if (ClusterClient == null) {
+            throw new IllegalStateException(
+                    "cluster_client not defined. You must add this "
+                            + "object to your global config. See google/atp/cluster.xml.");
+        }
+
+        return ClusterClient;
+    }
+}
diff --git a/src/com/android/tradefed/cluster/ClusterLogSaver.java b/src/com/android/tradefed/cluster/ClusterLogSaver.java
new file mode 100644
index 0000000..f48717f
--- /dev/null
+++ b/src/com/android/tradefed/cluster/ClusterLogSaver.java
@@ -0,0 +1,427 @@
+/*
+ * Copyright (C) 2019 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.cluster;
+
+import com.android.annotations.VisibleForTesting;
+import com.android.tradefed.config.GlobalConfiguration;
+import com.android.tradefed.config.Option;
+import com.android.tradefed.config.OptionClass;
+import com.android.tradefed.invoker.IInvocationContext;
+import com.android.tradefed.log.LogUtil.CLog;
+import com.android.tradefed.result.ILogSaver;
+import com.android.tradefed.result.LogDataType;
+import com.android.tradefed.result.LogFile;
+import com.android.tradefed.result.LogFileSaver;
+import com.android.tradefed.util.FileUtil;
+
+import org.json.JSONException;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.MalformedURLException;
+import java.net.URI;
+import java.nio.file.FileSystem;
+import java.nio.file.FileSystems;
+import java.nio.file.FileVisitOption;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.TreeSet;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+/** A {@link ILogSaver} class to upload test outputs to TFC. */
+@OptionClass(alias = "cluster", global_namespace = false)
+public class ClusterLogSaver implements ILogSaver {
+
+    /** A name of a text file containing all test output file names. */
+    public static final String FILE_NAMES_FILE_NAME = "FILES";
+
+    /** A name of a subdirectory containing all files generated by host process. */
+    public static final String TOOL_LOG_PATH = "tool-logs";
+
+    /** File picking strategies. */
+    public static enum FilePickingStrategy {
+        PICK_LAST,
+        PICK_FIRST
+    }
+
+    @Option(name = "root-dir", description = "A root directory", mandatory = true)
+    private File mRootDir;
+
+    @Option(name = "request-id", description = "A request ID", mandatory = true)
+    private String mRequestId;
+
+    @Option(name = "command-id", description = "A command ID", mandatory = true)
+    private String mCommandId;
+
+    @Option(name = "attempt-id", description = "A command attempt ID", mandatory = true)
+    private String mAttemptId;
+
+    @Option(
+            name = "output-file-upload-url",
+            description = "URL to upload output files to",
+            mandatory = true)
+    private String mOutputFileUploadUrl;
+
+    @Option(name = "output-file-pattern", description = "Output file patterns")
+    private List<String> mOutputFilePatterns = new ArrayList<>();
+
+    @Option(
+            name = "context-file-pattern",
+            description =
+                    "A regex pattern for test context file(s). A test context file is a"
+                            + " file to be used in successive invocations to pass context information.")
+    private String mContextFilePattern = null;
+
+    @Option(name = "file-picking-strategy", description = "A picking strategy for file(s)")
+    private FilePickingStrategy mFilePickingStrategy = FilePickingStrategy.PICK_LAST;
+
+    @Option(
+            name = "extra-context-file",
+            description =
+                    "Additional files to include in the context file. "
+                            + "Context file must be a ZIP archive.")
+    private List<String> mExtraContextFiles = new ArrayList<>();
+
+    @Option(
+            name = "retry-command-line",
+            description =
+                    "A command line to store in test context. This will replace the original command line in a retry invocation.")
+    private String mRetryCommandLine = null;
+
+    private File mLogDir;
+    private LogFileSaver mLogFileSaver = null;
+    private IClusterClient mClusterClient = null;
+
+    @Override
+    public void invocationStarted(IInvocationContext context) {
+        mLogDir = new File(mRootDir, "logs");
+        mLogFileSaver = new LogFileSaver(mLogDir);
+    }
+
+    private Path getRelativePath(Path p) {
+        return mRootDir.toPath().relativize(p);
+    }
+
+    /** Returns a Path stream for all files under a directory matching a given pattern. */
+    private Stream<Path> getPathStream(final File dir, final Pattern pattern) throws IOException {
+        return Files.find(
+                dir.toPath(),
+                Integer.MAX_VALUE,
+                (path, attr) ->
+                        attr.isRegularFile()
+                                && pattern.matcher(getRelativePath(path).toString()).matches(),
+                FileVisitOption.FOLLOW_LINKS);
+    }
+
+    private Set<File> findFilesRecursively(final File dir, final String regex) {
+        final Pattern pattern = Pattern.compile(regex);
+        try (final Stream<Path> stream = getPathStream(dir, pattern)) {
+            return stream.map(Path::toFile).collect(Collectors.toSet());
+        } catch (IOException e) {
+            throw new RuntimeException("Failed to collect output files", e);
+        }
+    }
+
+    private Set<String> getGroupNames(final String regex) {
+        final Set<String> names = new TreeSet<>();
+        Matcher m = Pattern.compile("\\(\\?<([a-zA-Z][a-zA-Z0-9]*)>").matcher(regex);
+        while (m.find()) {
+            names.add(m.group(1));
+        }
+        return names;
+    }
+
+    /**
+     * Find a test context file and collect environment vars if exists.
+     *
+     * <p>If there are multiple matches, it will only collect the first or last one in
+     * lexicographical order according to a given file picking strategy. If a newly collected
+     * environment var already exists in a given map, it will be overridden.
+     *
+     * @param dir a root directory.
+     * @param regex a regex pattern for a context file path. A relative path is used for matching.
+     * @param strategy a file picking strategy.
+     * @param envVars a map for environment vars.
+     * @return a {@link File} object.
+     */
+    @VisibleForTesting
+    File findTestContextFile(
+            final File dir,
+            final String regex,
+            final FilePickingStrategy strategy,
+            final Map<String, String> envVars) {
+        final Pattern pattern = Pattern.compile(regex);
+        try (Stream<Path> stream = getPathStream(dir, pattern)) {
+            Optional<Path> op = null;
+            switch (strategy) {
+                case PICK_FIRST:
+                    op = stream.sorted().findFirst();
+                    break;
+                case PICK_LAST:
+                    op = stream.sorted(Comparator.reverseOrder()).findFirst();
+                    break;
+            }
+            if (op == null || !op.isPresent()) {
+                return null;
+            }
+            final Path p = op.get();
+            Set<String> groupNames = getGroupNames(regex);
+            CLog.d("Context var names: %s", groupNames);
+            Path relPath = dir.toPath().relativize(p);
+            Matcher matcher = pattern.matcher(relPath.toString());
+            // One needs to call matches() before calling group() method.
+            matcher.matches();
+            for (final String name : groupNames) {
+                final String value = matcher.group(name);
+                if (value == null) {
+                    continue;
+                }
+                envVars.put(name, value);
+            }
+            return p.toFile();
+        } catch (IOException e) {
+            throw new RuntimeException("Failed to collect a context file", e);
+        }
+    }
+
+    /** Determine a file's new path after applying an optional prefix. */
+    private String getDestinationPath(String prefix, File file) {
+        String filename = file.getName();
+        return prefix == null ? filename : Paths.get(prefix, filename).toString();
+    }
+
+    /**
+     * Create a text file containing a list of file names.
+     *
+     * @param filenames filenames to write.
+     * @param destFile a {@link File} where to write names to.
+     * @throws IOException if writing fails
+     */
+    private void writeFilenamesToFile(Set<String> filenames, File destFile) throws IOException {
+        String content = filenames.stream().sorted().collect(Collectors.joining("\n"));
+        FileUtil.writeToFile(content, destFile);
+    }
+
+    /**
+     * Upload files to mOutputFileUploadUrl.
+     *
+     * @param fileMap a {@link Map} of file and destination path string pairs.
+     * @return a {@link Map} of file and URL pairs.
+     */
+    private Map<File, String> uploadFiles(Map<File, String> fileMap, FilePickingStrategy strategy) {
+        // construct a map of unique destination paths and files, to prevent duplicate uploads
+        Map<String, File> destinationMap =
+                fileMap.entrySet()
+                        .stream()
+                        .sorted(Comparator.comparing(Map.Entry::getKey)) // sort by filename
+                        .collect(
+                                Collectors.toMap(
+                                        e -> getDestinationPath(e.getValue(), e.getKey()),
+                                        Map.Entry::getKey,
+                                        // use strategy if two files have the same destination
+                                        (first, second) ->
+                                                strategy == FilePickingStrategy.PICK_FIRST
+                                                        ? first
+                                                        : second));
+        fileMap.keySet().retainAll(destinationMap.values());
+        CLog.i("Collected %d files to upload", fileMap.size());
+        fileMap.keySet().forEach(f -> CLog.i(f.getAbsolutePath()));
+
+        // Create a file names file.
+        File fileNamesFile = new File(mRootDir, FILE_NAMES_FILE_NAME);
+        try {
+            writeFilenamesToFile(destinationMap.keySet(), fileNamesFile);
+        } catch (IOException e) {
+            CLog.e("Failed to write %s", fileNamesFile.getAbsolutePath());
+        }
+
+        final TestOutputUploader uploader = getTestOutputUploader();
+        try {
+            uploader.setUploadUrl(mOutputFileUploadUrl);
+        } catch (MalformedURLException e) {
+            throw new RuntimeException("Failed to set upload URL", e);
+        }
+
+        fileMap.put(fileNamesFile, null);
+        final Map<File, String> fileUrls = new TreeMap<>();
+        int index = 1;
+        for (Map.Entry<File, String> entry : fileMap.entrySet()) {
+            File file = entry.getKey();
+            CLog.i("Uploading file %d of %d: %s", index, fileMap.size(), file.getAbsolutePath());
+            try {
+                fileUrls.put(file, uploader.uploadFile(file, entry.getValue()));
+            } catch (IOException | RuntimeException e) {
+                CLog.e("Failed to upload %s: %s", file, e);
+            }
+            index++;
+        }
+        return fileUrls;
+    }
+
+    /** If the context file is a zip file, will append the specified files to it. */
+    @VisibleForTesting
+    void appendFilesToContext(File contextFile, List<String> filesToAdd) {
+        if (filesToAdd.isEmpty()) {
+            return;
+        }
+
+        // create new ZIP file system which allows creating files
+        URI uri = URI.create("jar:" + contextFile.toURI());
+        Map<String, String> env = new HashMap<>();
+        env.put("create", "true");
+        try (FileSystem zip = FileSystems.newFileSystem(uri, env)) {
+            // copy files into the zip file, will not overwrite existing files
+            for (String filename : filesToAdd) {
+                Path path = Paths.get(filename);
+                if (!path.isAbsolute()) {
+                    path = mRootDir.toPath().resolve(path);
+                }
+                if (!path.toFile().exists()) {
+                    CLog.w("File %s not found", path);
+                    continue;
+                }
+                Path zipPath = zip.getPath(path.getFileName().toString());
+                Files.copy(path, zipPath);
+            }
+        } catch (IOException | RuntimeException e) {
+            CLog.w("Failed to append files to context");
+            CLog.e(e);
+        }
+    }
+
+    @Override
+    public void invocationEnded(long elapsedTime) {
+        // Key is the file to be uploaded. Value is the destination path to upload url.
+        // For example, to upload a.txt to uploadUrl/path1/, the destination path is "path1";
+        // To upload a.txt to uploadUrl/, the destination path is null.
+        final Map<File, String> outputFiles = new HashMap<>();
+        File contextFile = null;
+        Map<String, String> envVars = new TreeMap<>();
+
+        // Get a list of log files to upload (skip host_log_*.txt to prevent duplicate upload)
+        findFilesRecursively(mLogDir, "^((?!host_log_\\d+).)*$")
+                .forEach(file -> outputFiles.put(file, TOOL_LOG_PATH));
+
+        // Collect output files to upload
+        if (0 < mOutputFilePatterns.size()) {
+            final String regex =
+                    mOutputFilePatterns
+                            .stream()
+                            .map((s) -> "(" + s + ")")
+                            .collect(Collectors.joining("|"));
+            CLog.i("Collecting output files matching regex: " + regex);
+            findFilesRecursively(mRootDir, regex).forEach(file -> outputFiles.put(file, null));
+        }
+
+        // Collect a context file if exists.
+        if (mContextFilePattern != null) {
+            CLog.i("Collecting context file matching regex: " + mContextFilePattern);
+            contextFile =
+                    findTestContextFile(
+                            mRootDir, mContextFilePattern, mFilePickingStrategy, envVars);
+            if (contextFile != null) {
+                CLog.i("Context file = %s", contextFile.getAbsolutePath());
+                outputFiles.put(contextFile, null);
+                appendFilesToContext(contextFile, mExtraContextFiles);
+            } else {
+                CLog.i("No context file found");
+            }
+        }
+
+        final Map<File, String> outputFileUrls = uploadFiles(outputFiles, mFilePickingStrategy);
+        if (contextFile != null && outputFileUrls.containsKey(contextFile)) {
+            final IClusterClient client = getClusterClient();
+            final TestContext testContext = new TestContext();
+            testContext.setCommandLine(mRetryCommandLine);
+            testContext.addEnvVars(envVars);
+            final String name = getRelativePath(contextFile.toPath()).toString();
+            testContext.addTestResource(new TestResource(name, outputFileUrls.get(contextFile)));
+            try {
+                CLog.i("Updating test context: %s", testContext.toString());
+                client.updateTestContext(mRequestId, mCommandId, testContext);
+            } catch (IOException | JSONException e) {
+                throw new RuntimeException("failed to update test context", e);
+            }
+        }
+    }
+
+    @VisibleForTesting
+    TestOutputUploader getTestOutputUploader() {
+        return new TestOutputUploader();
+    }
+
+    /** Get the {@link IClusterClient} instance used to interact with the TFC backend. */
+    @VisibleForTesting
+    IClusterClient getClusterClient() {
+        if (mClusterClient == null) {
+            mClusterClient =
+                    (IClusterClient)
+                            GlobalConfiguration.getInstance()
+                                    .getConfigurationObject(IClusterClient.TYPE_NAME);
+            if (mClusterClient == null) {
+                throw new IllegalStateException("cluster_client not defined in TF global config.");
+            }
+        }
+        return mClusterClient;
+    }
+
+    @Override
+    public LogFile saveLogData(String dataName, LogDataType dataType, InputStream dataStream)
+            throws IOException {
+        File log = mLogFileSaver.saveLogData(dataName, dataType, dataStream);
+        return new LogFile(log.getAbsolutePath(), null, dataType);
+    }
+
+    @Override
+    public LogFile saveLogDataRaw(String dataName, LogDataType dataType, InputStream dataStream)
+            throws IOException {
+        File log = mLogFileSaver.saveLogDataRaw(dataName, dataType.getFileExt(), dataStream);
+        return new LogFile(log.getAbsolutePath(), null, dataType);
+    }
+
+    @Override
+    public LogFile getLogReportDir() {
+        return new LogFile(mLogDir.getAbsolutePath(), null, LogDataType.DIR);
+    }
+
+    @VisibleForTesting
+    String getAttemptId() {
+        return mAttemptId;
+    }
+
+    @VisibleForTesting
+    String getOutputFileUploadUrl() {
+        return mOutputFileUploadUrl;
+    }
+
+    @VisibleForTesting
+    List<String> getOutputFilePatterns() {
+        return mOutputFilePatterns;
+    }
+}
diff --git a/src/com/android/tradefed/cluster/ClusterOptions.java b/src/com/android/tradefed/cluster/ClusterOptions.java
new file mode 100644
index 0000000..e6d6a45
--- /dev/null
+++ b/src/com/android/tradefed/cluster/ClusterOptions.java
@@ -0,0 +1,282 @@
+/*
+ * Copyright (C) 2019 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.cluster;
+
+import com.android.tradefed.config.GlobalConfiguration;
+import com.android.tradefed.config.IConfiguration;
+import com.android.tradefed.config.Option;
+import com.android.tradefed.config.OptionClass;
+import com.android.tradefed.util.MultiMap;
+
+import com.google.common.annotations.VisibleForTesting;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/*
+ * A {@link IClusterOptions} implementation which contains cluster-related options.
+ */
+@OptionClass(alias = "cluster", global_namespace = false)
+public class ClusterOptions implements IClusterOptions {
+
+    /**
+     * The unique configuration object type name. Used to retrieve the singleton instance from the
+     * {@link GlobalConfiguration}.
+     *
+     * @see IConfiguration#getConfigurationObject(String)
+     */
+    public static final String TYPE_NAME = "cluster_options";
+
+    @Option(name = "service-url", description = "the base url of the tradefed cluster REST API")
+    public String mServiceUrl = null;
+
+    // TODO: The "service-account-keyfile" option should be HostOption (AOSP HostOptions.java).
+    @Option(
+            name = "service-account-keyfile",
+            description =
+                    "The service account json key file. "
+                            + "This is used by tradefed test scheduler (e.g. Tradefed Cluster) to "
+                            + "authenticate the tradefed host. "
+                            + "See google doc for the definition and how service account key works. "
+                            + "https://cloud.google.com/iam/docs/service-accounts ")
+    private File mSchedulerServiceAccountKeyfile = null;
+
+    @Option(name = "cluster", description = "the cluster id for this TF instance", mandatory = true)
+    public String mClusterId = null;
+
+    @Option(
+            name = "next-cluster",
+            description =
+                    "seconadary clusters for this TF instance to run commands from. If "
+                            + "this option is set, TF will try to lease commands from these clusters in "
+                            + "the order they are specified if it still has available devices after "
+                            + "leasing commands from the primary cluster.")
+    public List<String> mNextClusterIds = new ArrayList<>();
+
+    @Option(name = "run-target-format", description = "the format for labelling run targets.")
+    private String mRunTargetFormat = null;
+
+    @Option(name = "disable-device-monitor", description = "disable Cluster device reporting")
+    private boolean mIsDeviceMonitorDisabled = false;
+
+    @Option(
+            name = "device-monitor-interval",
+            isTimeVal = true,
+            description = "the time interval between each device snapshot")
+    private long mDeviceMonitorSnapshotInterval = 60 * 1000;
+
+    @Option(
+            name = "device-group",
+            description =
+                    "A multi-map from device group to device serials."
+                            + " The key is a device group name and value is device serial.")
+    private MultiMap<String, String> mDeviceGroup = new MultiMap<String, String>();
+
+    @Option(
+            name = "device-tag",
+            description =
+                    "A map for tagging device serials; each device may "
+                            + "have one tag. This can be used for reporting in run-target")
+    private Map<String, String> mDeviceTag = new HashMap<>();
+
+    @Option(
+            name = "check-flashing-permits-on-lease",
+            description = "Check available flashing permits when leasing tasks")
+    private boolean mCheckFlashingPermitsOnLease = true;
+
+    @Option(
+            name = "invocation-heartbeat-interval",
+            isTimeVal = true,
+            description = "The time interval between invocation heartbeats")
+    private long mInvocationHeartbeatInterval = 5 * 60 * 1000;
+
+    @Option(name = "upload-invocation-status", description = "Upload invocation status to TFC")
+    private Boolean mShouldUploadInvocationStatus = false;
+
+    @Option(
+            name = "check-command-state",
+            description = "Check cluster command state to detect canceled invocations")
+    private boolean mCheckCommandState = false;
+
+    @Option(name = "connect-timeout", description = "HTTP connect timeout.", isTimeVal = true)
+    private int mConnectTimeout = 60000;
+
+    @Option(name = "read-timeout", description = "HTTP read timeout.", isTimeVal = true)
+    private int mReadTimeout = 60000;
+
+    /** {@inheritDoc} */
+    @Override
+    public String getServiceUrl() {
+        return mServiceUrl;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public String getClusterId() {
+        return mClusterId;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public List<String> getNextClusterIds() {
+        return mNextClusterIds;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public MultiMap<String, String> getDeviceGroup() {
+        return mDeviceGroup;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Map<String, String> getDeviceTag() {
+        return mDeviceTag;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean checkFlashingPermitsOnLease() {
+        return mCheckFlashingPermitsOnLease;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public String getRunTargetFormat() {
+        return mRunTargetFormat;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean isDeviceMonitorDisabled() {
+        return mIsDeviceMonitorDisabled;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public long getDeviceMonitorSnapshotInterval() {
+        return mDeviceMonitorSnapshotInterval;
+    }
+
+    /**
+     * Set the base url of the tradefed cluster REST API.
+     *
+     * <p>Exposed for testing.
+     */
+    void setServiceUrl(String url) {
+        mServiceUrl = url;
+    }
+
+    /**
+     * Set the cluster id for this TF instance.
+     *
+     * <p>Exposed for testing.
+     */
+    void setClusterId(String id) {
+        mClusterId = id;
+    }
+
+    /**
+     * Set the format for labelling run targets.
+     *
+     * <p>Exposed for testing.
+     */
+    void setRunTargetFormat(String format) {
+        mRunTargetFormat = format;
+    }
+
+    /**
+     * Set whether Cluster device reporting is disabled.
+     *
+     * <p>Exposed for testing.
+     */
+    void setDeviceMonitorDisabled(boolean disabled) {
+        mIsDeviceMonitorDisabled = disabled;
+    }
+
+    /**
+     * Set the time interval between each device snapshot in ms.
+     *
+     * <p>Exposed for testing.
+     */
+    void setDeviceMonitorSnapshotInterval(long interval) {
+        mDeviceMonitorSnapshotInterval = interval;
+    }
+
+    /**
+     * Set whether the scheduler should check if there are available flashing permits.
+     *
+     * <p>Exposed for testing.
+     */
+    void setCheckFlashingPermitsLease(boolean checkFlashingPermitsLease) {
+        mCheckFlashingPermitsOnLease = checkFlashingPermitsLease;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public long getInvocationHeartbeatInterval() {
+        return mInvocationHeartbeatInterval;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Boolean shouldUploadInvocationStatus() {
+        return mShouldUploadInvocationStatus;
+    }
+
+    /** Set the service account key file. */
+    @VisibleForTesting
+    void setSchedulerServiceAccountKeyfile(File keyFile) {
+        mSchedulerServiceAccountKeyfile = keyFile;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public File getSchedulerServiceAccountKeyfile() {
+        return mSchedulerServiceAccountKeyfile;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public String getSchedulerServiceUrl() {
+        return mServiceUrl;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public int getConnectTimeout() {
+        return mConnectTimeout;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public int getReadTimeout() {
+        return mReadTimeout;
+    }
+
+    @Override
+    public boolean checkCommandState() {
+        return mCheckCommandState;
+    }
+
+    @VisibleForTesting
+    void setCheckCommandState(boolean checkCommandState) {
+        mCheckCommandState = checkCommandState;
+    }
+}
diff --git a/src/com/android/tradefed/cluster/IClusterClient.java b/src/com/android/tradefed/cluster/IClusterClient.java
new file mode 100644
index 0000000..e803d7a
--- /dev/null
+++ b/src/com/android/tradefed/cluster/IClusterClient.java
@@ -0,0 +1,101 @@
+/*
+ * Copyright (C) 2019 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.cluster;
+
+import com.android.tradefed.config.GlobalConfiguration;
+import com.android.tradefed.config.IConfiguration;
+
+import org.json.JSONException;
+
+import java.io.IOException;
+import java.util.List;
+
+/** An interface for interacting with the TFC backend. */
+public interface IClusterClient {
+    /**
+     * The unique configuration object type name. Used to retrieve the singleton instance from the
+     * {@link GlobalConfiguration}.
+     *
+     * @see IConfiguration#getConfigurationObject(String)
+     */
+    public static final String TYPE_NAME = "cluster_client";
+
+    /**
+     * Get a {@link IClusterEventUploader} that can be used to upload {@link ClusterCommandEvent}s.
+     */
+    public IClusterEventUploader<ClusterCommandEvent> getCommandEventUploader();
+
+    /** Get a {@link IClusterEventUploader} that can be used to upload {@link ClusterHostEvent}s. */
+    public IClusterEventUploader<ClusterHostEvent> getHostEventUploader();
+
+    /**
+     * Lease {@link ClusterCommand} for the give host.
+     *
+     * @param clusterId cluster id for the host
+     * @param hostname hostname
+     * @param devices deviceInfos the host has
+     * @param nextClusterIds a list of next cluster IDs to lease commands from.
+     * @param maxTasksTolease the max number of tasks that can current be leased
+     * @return a list of {@link ClusterCommand}
+     * @throws JSONException
+     */
+    public List<ClusterCommand> leaseHostCommands(
+            final String clusterId,
+            final String hostname,
+            final List<ClusterDeviceInfo> devices,
+            final List<String> nextClusterIds,
+            final int maxTasksTolease)
+            throws JSONException;
+
+    /**
+     * Get {@link TestEnvironment} for a request.
+     *
+     * @param requestId
+     * @return a {@link TestEnvironment} object.
+     * @throws IOException
+     * @throws JSONException
+     */
+    public TestEnvironment getTestEnvironment(final String requestId)
+            throws IOException, JSONException;
+
+    /**
+     * Get {@link TestResource}s for a request.
+     *
+     * @param requestId
+     * @return a list of {@link TestResource}.
+     * @throws IOException
+     * @throws JSONException
+     */
+    public List<TestResource> getTestResources(final String requestId)
+            throws IOException, JSONException;
+
+    public TestContext getTestContext(final String requestId, final String commandId)
+            throws IOException, JSONException;
+
+    public void updateTestContext(
+            final String requestId, final String commandId, TestContext testContext)
+            throws IOException, JSONException;
+
+    /**
+     * Determine the state of a cluster command.
+     *
+     * @param requestId cluster request ID
+     * @param commandId cluster command ID
+     * @return cluster command's state, or {@link ClusterCommand.State#UNKNOWN} if state could not
+     *     be determined
+     */
+    public ClusterCommand.State getCommandState(String requestId, String commandId);
+}
diff --git a/src/com/android/tradefed/cluster/IClusterEvent.java b/src/com/android/tradefed/cluster/IClusterEvent.java
new file mode 100644
index 0000000..33d07b6
--- /dev/null
+++ b/src/com/android/tradefed/cluster/IClusterEvent.java
@@ -0,0 +1,25 @@
+/*
+ * Copyright (C) 2019 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.cluster;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+/** Interface for any cluster event to be uploaded to TFC. */
+public interface IClusterEvent {
+
+    public JSONObject toJSON() throws JSONException;
+}
diff --git a/src/com/android/tradefed/cluster/IClusterEventUploader.java b/src/com/android/tradefed/cluster/IClusterEventUploader.java
new file mode 100644
index 0000000..72ee6e7
--- /dev/null
+++ b/src/com/android/tradefed/cluster/IClusterEventUploader.java
@@ -0,0 +1,58 @@
+/*
+ * Copyright (C) 2019 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.cluster;
+
+/** Interface for ClusterEventUploader */
+public interface IClusterEventUploader<T extends IClusterEvent> {
+    /**
+     * Get the maximum number of events to upload at once.
+     *
+     * @param batchSize the maximum number of events to upload at once.
+     */
+    public void setMaxBatchSize(int batchSize);
+
+    /**
+     * Get the maximum batch size used when uploading events.
+     *
+     * @return the maximum batch size.
+     */
+    public int getMaxBatchSize();
+
+    /**
+     * Set how often we upload events to TFC.
+     *
+     * @param interval in ms for events to be uploaded to TFC.
+     */
+    public void setEventUploadInterval(long interval);
+
+    /**
+     * Get the upload interval.
+     *
+     * @return the upload interval in ms.
+     */
+    public long getEventUploadInterval();
+
+    /**
+     * Posts an event to TFC. This queues the event to be uploaded. Events will be batched and
+     * uploaded.
+     *
+     * @param event the event to upload
+     */
+    void postEvent(final T event);
+
+    /** Force an upload of all events queued. */
+    void flush();
+}
diff --git a/src/com/android/tradefed/cluster/IClusterOptions.java b/src/com/android/tradefed/cluster/IClusterOptions.java
new file mode 100644
index 0000000..c23f407
--- /dev/null
+++ b/src/com/android/tradefed/cluster/IClusterOptions.java
@@ -0,0 +1,73 @@
+/*
+ * Copyright (C) 2019 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.cluster;
+
+import com.android.tradefed.util.MultiMap;
+import java.io.File;
+import java.util.List;
+import java.util.Map;
+
+/** An interface for getting cluster-related options. */
+public interface IClusterOptions {
+
+    /** Get the base url of the tradefed cluster REST API. */
+    public String getServiceUrl();
+
+    /** Get the cluster id for this TF instance. */
+    public String getClusterId();
+
+    /** Get the secondary cluster ids for this TF instance. */
+    public List<String> getNextClusterIds();
+
+    /** Get the device group to device mapping. */
+    public MultiMap<String, String> getDeviceGroup();
+
+    /** Get the device serial to tag mapping. */
+    public Map<String, String> getDeviceTag();
+
+    /** Check if it should check for available flashing permits before leasing. */
+    boolean checkFlashingPermitsOnLease();
+
+    /** Get the format for labelling run targets. */
+    public String getRunTargetFormat();
+
+    /** Returns whether Cluster device reporting is disabled. */
+    public boolean isDeviceMonitorDisabled();
+
+    /** Get the time interval between each device snapshot in ms. */
+    public long getDeviceMonitorSnapshotInterval();
+
+    /** Returns whether TF should upload invocation status. */
+    public Boolean shouldUploadInvocationStatus();
+
+    /** Get the time interval between invocation heartbeats in ms. */
+    public long getInvocationHeartbeatInterval();
+
+    /** Get http connect timeout. */
+    public int getConnectTimeout();
+
+    /** Get http read timeout. */
+    public int getReadTimeout();
+
+    /** Get the tradefed test scheduler service account key file. */
+    public File getSchedulerServiceAccountKeyfile();
+
+    /** Get the tradefed test scheduler service URL. */
+    public String getSchedulerServiceUrl();
+
+    /** Whether the command state (on the TF cluster) should be checked during heartbeat. */
+    public boolean checkCommandState();
+}
diff --git a/src/com/android/tradefed/cluster/InvocationStatus.java b/src/com/android/tradefed/cluster/InvocationStatus.java
new file mode 100644
index 0000000..34bcfdc
--- /dev/null
+++ b/src/com/android/tradefed/cluster/InvocationStatus.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright (C) 2019 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.cluster;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * A class to store invocation status.
+ *
+ * <p>This also includes statuses of test groups that run within the invocation.
+ */
+public class InvocationStatus {
+
+    private List<TestGroupStatus> mTestGroupStatuses = new ArrayList<TestGroupStatus>();
+
+    public void addTestGroupStatus(final TestGroupStatus progress) {
+        mTestGroupStatuses.add(progress);
+    }
+
+    public List<TestGroupStatus> getTestGroupStatuses() {
+        return Collections.unmodifiableList(mTestGroupStatuses);
+    }
+
+    public JSONObject toJSON() throws JSONException {
+        final JSONObject json = new JSONObject();
+        final JSONArray testModuleStatuses = new JSONArray();
+        for (final TestGroupStatus o : mTestGroupStatuses) {
+            testModuleStatuses.put(o.toJSON());
+        }
+        json.put("test_group_statuses", testModuleStatuses);
+        return json;
+    }
+}
diff --git a/src/com/android/tradefed/cluster/SubprocessConfigBuilder.java b/src/com/android/tradefed/cluster/SubprocessConfigBuilder.java
new file mode 100644
index 0000000..2b32638
--- /dev/null
+++ b/src/com/android/tradefed/cluster/SubprocessConfigBuilder.java
@@ -0,0 +1,100 @@
+/*
+ * Copyright (C) 2019 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.cluster;
+
+import com.android.tradefed.config.Configuration;
+import com.android.tradefed.config.ConfigurationUtil;
+import com.android.tradefed.result.LegacySubprocessResultsReporter;
+
+import org.kxml2.io.KXmlSerializer;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.PrintWriter;
+
+/**
+ * Build a wrapper TF config XML for an existing TF config.
+ *
+ * <p>A wrapper XML allows to enable subprocess reporting on an existing TF config.
+ */
+public class SubprocessConfigBuilder {
+    private static final String INCLUDE_NAME = "include";
+    private static final String REPORTER_CLASS = LegacySubprocessResultsReporter.class.getName();
+    private static final String OPTION_KEY = "subprocess-report-port";
+    private static final String CONFIG_DESCRIPTION = "Cluster Command Launcher config";
+
+    private File mWorkdir;
+
+    private String mOriginalConfig;
+
+    private String mPort;
+
+    public SubprocessConfigBuilder setWorkingDir(File dir) {
+        mWorkdir = dir;
+        return this;
+    }
+
+    public SubprocessConfigBuilder setOriginalConfig(String config) {
+        mOriginalConfig = config;
+        return this;
+    }
+
+    public SubprocessConfigBuilder setPort(String port) {
+        mPort = port;
+        return this;
+    }
+
+    public File build() throws IOException {
+        // Make a new config name based on the original config name to make it possible to find
+        // out the original command line from a modified one.
+        // FIXME: Find a better way to preserve the original command line.
+        String configName = mOriginalConfig.replace("/", "$") + ".xml";
+        // mOriginalConfig is from another test suite, so its content is hard to know at this
+        // time. So it doesn't load mOriginalConfig as IConfiguration and add additional config.
+        // Instead, it creates a wrapper config including mOriginalConfig.
+        File f = new File(mWorkdir, configName);
+        PrintWriter writer = new PrintWriter((f));
+        KXmlSerializer serializer = new KXmlSerializer();
+        serializer.setOutput(writer);
+        serializer.setFeature("http://xmlpull.org/v1/doc/features.html#indent-output", true);
+        serializer.startDocument("UTF-8", null);
+        serializer.startTag(null, ConfigurationUtil.CONFIGURATION_NAME);
+        serializer.attribute(
+                null, Configuration.CONFIGURATION_DESCRIPTION_TYPE_NAME, CONFIG_DESCRIPTION);
+
+        serializer.startTag(null, INCLUDE_NAME);
+        serializer.attribute(null, ConfigurationUtil.NAME_NAME, mOriginalConfig);
+        serializer.endTag(null, INCLUDE_NAME);
+
+        if (mPort != null) {
+            serializer.startTag(null, Configuration.RESULT_REPORTER_TYPE_NAME);
+            serializer.attribute(null, ConfigurationUtil.CLASS_NAME, REPORTER_CLASS);
+
+            serializer.startTag(null, ConfigurationUtil.OPTION_NAME);
+            serializer.attribute(null, ConfigurationUtil.NAME_NAME, OPTION_KEY);
+            serializer.attribute(null, ConfigurationUtil.VALUE_NAME, mPort);
+            serializer.endTag(null, ConfigurationUtil.OPTION_NAME);
+
+            serializer.endTag(null, Configuration.RESULT_REPORTER_TYPE_NAME);
+        }
+
+        serializer.endTag(null, ConfigurationUtil.CONFIGURATION_NAME);
+        serializer.endDocument();
+
+        writer.close();
+        return f;
+    }
+}
diff --git a/src/com/android/tradefed/cluster/SubprocessReportingHelper.java b/src/com/android/tradefed/cluster/SubprocessReportingHelper.java
new file mode 100644
index 0000000..d48522e
--- /dev/null
+++ b/src/com/android/tradefed/cluster/SubprocessReportingHelper.java
@@ -0,0 +1,133 @@
+/*
+ * Copyright (C) 2019 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.cluster;
+
+import com.android.tradefed.log.LogUtil;
+import com.android.tradefed.result.LegacySubprocessResultsReporter;
+import com.android.tradefed.util.FileUtil;
+import com.android.tradefed.util.QuotationAwareTokenizer;
+import com.android.tradefed.util.StreamUtil;
+import com.android.tradefed.util.ZipUtil2;
+
+import java.io.BufferedInputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.util.Set;
+import java.util.jar.JarEntry;
+import java.util.jar.JarOutputStream;
+import java.util.jar.Manifest;
+
+/**
+ * A class to build a wrapper configuration file to use subprocess results reporter for a cluster
+ * command.
+ */
+public class SubprocessReportingHelper {
+    private static final String REPORTER_JAR_NAME = "subprocess-results-reporter.jar";
+    private static final String CLASS_FILTER =
+            String.format(
+                    "(^%s|^%s|^%s|^%s|^%s).*class$",
+                    "LegacySubprocessResultsReporter",
+                    "SubprocessTestResultsParser",
+                    "SubprocessEventHelper",
+                    "SubprocessResultsReporter",
+                    "ISupportGranularResults");
+
+    /**
+     * Dynamically generate extract .class file from tradefed.jar and generate new subprocess
+     * results reporter jar.
+     *
+     * @param parentDir parent directory of subprocess results reporter jar.
+     * @return subprocess result reporter jar.
+     * @throws IOException
+     */
+    public File createSubprocessReporterJar(File parentDir) throws IOException {
+        File reporterJar = new File(parentDir, REPORTER_JAR_NAME);
+        File tfJar =
+                new File(
+                        LegacySubprocessResultsReporter.class
+                                .getProtectionDomain()
+                                .getCodeSource()
+                                .getLocation()
+                                .getPath());
+        // tfJar is directory of .class file when running JUnit test from Eclipse IDE
+        if (tfJar.isDirectory()) {
+            Set<File> classFiles = FileUtil.findFilesObject(tfJar, CLASS_FILTER);
+            Manifest manifest = new Manifest();
+            createJar(reporterJar, manifest, classFiles);
+        }
+        // tfJar is the tradefed.jar when running with tradefed.
+        else {
+            File extractedJar = ZipUtil2.extractZipToTemp(tfJar, "tmp-jar");
+            try {
+                Set<File> classFiles = FileUtil.findFilesObject(extractedJar, CLASS_FILTER);
+                File mf = FileUtil.findFile(extractedJar, "MANIFEST.MF");
+                Manifest manifest = new Manifest(new FileInputStream(mf));
+                createJar(reporterJar, manifest, classFiles);
+            } finally {
+                FileUtil.recursiveDelete(extractedJar);
+            }
+        }
+        return reporterJar;
+    }
+
+    /**
+     * Create jar file.
+     *
+     * @param jar jar file to be created.
+     * @param manifest manifest file.
+     * @throws IOException
+     */
+    private void createJar(File jar, Manifest manifest, Set<File> classFiles) throws IOException {
+        try (JarOutputStream jarOutput = new JarOutputStream(new FileOutputStream(jar), manifest)) {
+            for (File file : classFiles) {
+                try (BufferedInputStream in = new BufferedInputStream(new FileInputStream(file))) {
+                    String path = file.getPath();
+                    JarEntry entry = new JarEntry(path.substring(path.indexOf("com")));
+                    entry.setTime(file.lastModified());
+                    jarOutput.putNextEntry(entry);
+                    StreamUtil.copyStreams(in, jarOutput);
+                    jarOutput.closeEntry();
+                }
+            }
+        }
+    }
+
+    /**
+     * Get a new command line whose configuration argument is replaced by a newly-created wrapper
+     * configuration.
+     *
+     * <p>The resulting command line will reference a generate XML file in parentDir and needs to
+     * run from parentDir.
+     *
+     * @param commandLine old command line that will be run by subprocess.
+     * @param port port number that subprocess should use to report results.
+     * @param parentDir parent directory of new wrapper configuration.
+     * @return new command line, whose first argument is wrapper config.
+     * @throws IOException
+     */
+    public String buildNewCommandConfig(String commandLine, String port, File parentDir)
+            throws IOException {
+        String[] tokens = QuotationAwareTokenizer.tokenizeLine(commandLine);
+        SubprocessConfigBuilder builder = new SubprocessConfigBuilder();
+        builder.setWorkingDir(parentDir).setOriginalConfig(tokens[0]).setPort(port);
+        File f = builder.build();
+        LogUtil.CLog.i("Generating new configuration:\n %s", FileUtil.readStringFromFile(f));
+        tokens[0] = f.getName();
+        return QuotationAwareTokenizer.combineTokens(tokens);
+    }
+}
diff --git a/src/com/android/tradefed/cluster/TestContext.java b/src/com/android/tradefed/cluster/TestContext.java
new file mode 100644
index 0000000..2b9d38b
--- /dev/null
+++ b/src/com/android/tradefed/cluster/TestContext.java
@@ -0,0 +1,118 @@
+/*
+ * Copyright (C) 2019 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.cluster;
+
+import com.android.tradefed.log.LogUtil.CLog;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+/**
+ * A class to model a TestContext message of TFC API.
+ *
+ * <p>A TestContext message is used to store and retrieve contextual information being passed across
+ * multiple attempts of same test command.
+ */
+public class TestContext {
+
+    String mCommandLine = null;
+    final Map<String, String> mEnvVars = new TreeMap<>();
+    final List<TestResource> mTestResources = new ArrayList<>();
+
+    public String getCommandLine() {
+        return mCommandLine;
+    }
+
+    public void setCommandLine(String commandLine) {
+        mCommandLine = commandLine;
+    }
+
+    public Map<String, String> getEnvVars() {
+        return Collections.unmodifiableMap(mEnvVars);
+    }
+
+    public void addEnvVars(Map<String, String> envVars) {
+        mEnvVars.putAll(envVars);
+    }
+
+    public List<TestResource> getTestResources() {
+        return Collections.unmodifiableList(mTestResources);
+    }
+
+    public void addTestResource(TestResource testResource) {
+        mTestResources.add(testResource);
+    }
+
+    public static TestContext fromJson(JSONObject json) throws JSONException {
+        final TestContext obj = new TestContext();
+        obj.mCommandLine = json.optString("command_line");
+        final JSONArray envVars = json.optJSONArray("env_vars");
+        if (envVars != null) {
+            for (int i = 0; i < envVars.length(); i++) {
+                final JSONObject pair = envVars.getJSONObject(i);
+                obj.mEnvVars.put(pair.getString("key"), pair.getString("value"));
+            }
+        }
+        final JSONArray testResources = json.optJSONArray("test_resources");
+        if (testResources != null) {
+            obj.mTestResources.addAll(TestResource.fromJsonArray(testResources));
+        }
+        return obj;
+    }
+
+    public JSONObject toJson() throws JSONException {
+        final JSONObject json = new JSONObject();
+        json.put("command_line", mCommandLine);
+        final JSONArray envVars = new JSONArray();
+        for (Map.Entry<String, String> entry : mEnvVars.entrySet()) {
+            final JSONObject pair = new JSONObject();
+            pair.put("key", entry.getKey());
+            pair.put("value", entry.getValue());
+            envVars.put(pair);
+        }
+        json.put("env_vars", envVars);
+        final JSONArray testResources = new JSONArray();
+        for (TestResource obj : mTestResources) {
+            testResources.put(obj.toJson());
+        }
+        json.put("test_resources", testResources);
+        return json;
+    }
+
+    @Override
+    public String toString() {
+        try {
+            return toJson().toString();
+        } catch (JSONException e) {
+            CLog.w("Failed to convert to JSON: %s", e);
+        }
+        return null;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (!(o instanceof TestContext)) {
+            return false;
+        }
+
+        return toString().equals(o.toString());
+    }
+}
diff --git a/src/com/android/tradefed/cluster/TestEnvironment.java b/src/com/android/tradefed/cluster/TestEnvironment.java
new file mode 100644
index 0000000..9b9afb9
--- /dev/null
+++ b/src/com/android/tradefed/cluster/TestEnvironment.java
@@ -0,0 +1,256 @@
+/*
+ * Copyright (C) 2019 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.cluster;
+
+import com.android.annotations.VisibleForTesting;
+import com.android.tradefed.log.LogUtil.CLog;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+/** A class to model a TestEnvironment message returned by TFC API. */
+public class TestEnvironment {
+
+    final Map<String, String> mEnvVars = new HashMap<>();
+    final List<String> mSetupScripts = new ArrayList<>();
+    final List<String> mOutputFilePatterns = new ArrayList<>();
+    String mOutputFileUploadUrl = null;
+    boolean mUseSubprocessReporting = false;
+    long mOutputIdleTimeout = 0L;
+    final Map<String, String> mJavaProperties = new HashMap<>();
+    String mContextFilePattern = null;
+    private final List<String> mExtraContextFiles = new ArrayList<>();
+    String mRetryCommandLine = null;
+    String mLogLevel = null;
+    final List<TradefedConfigObject> mTradefedConfigObjects = new ArrayList<>();
+
+    /**
+     * Adds an environment variable.
+     *
+     * @param name a variable name.
+     * @param value a variable value.
+     */
+    public void addEnvVar(final String name, final String value) {
+        mEnvVars.put(name, value);
+    }
+
+    /**
+     * Returns a {@link Map} object containing all env vars.
+     *
+     * @return unmodifiable map of all env vars.
+     */
+    public Map<String, String> getEnvVars() {
+        return Collections.unmodifiableMap(mEnvVars);
+    }
+
+    /**
+     * Adds a setup script command.
+     *
+     * @param s a setup script command.
+     */
+    public void addSetupScripts(final String s) {
+        mSetupScripts.add(s);
+    }
+
+    /**
+     * Returns a list of setup script commands.
+     *
+     * @return unmodifiable list of commands
+     */
+    public List<String> getSetupScripts() {
+        return Collections.unmodifiableList(mSetupScripts);
+    }
+
+    /**
+     * Adds an output file pattern.
+     *
+     * @param s a file pattern.
+     */
+    public void addOutputFilePattern(final String s) {
+        mOutputFilePatterns.add(s);
+    }
+
+    /**
+     * Returns a list of output file patterns.
+     *
+     * @return unmodifiable list of file patterns.
+     */
+    public List<String> getOutputFilePatterns() {
+        return Collections.unmodifiableList(mOutputFilePatterns);
+    }
+
+    /**
+     * Sets an output file upload URL.
+     *
+     * @param s a URL.
+     */
+    public void setOutputFileUploadUrl(final String s) {
+        mOutputFileUploadUrl = s;
+    }
+
+    /**
+     * Returns an output file upload URL.
+     *
+     * @return a URL.
+     */
+    public String getOutputFileUploadUrl() {
+        return mOutputFileUploadUrl;
+    }
+
+    /**
+     * Returns whether to use subprocess reporting.
+     *
+     * @return a boolean.
+     */
+    public boolean useSubprocessReporting() {
+        return mUseSubprocessReporting;
+    }
+
+    public void setUseSubprocessReporting(boolean f) {
+        mUseSubprocessReporting = f;
+    }
+
+    /** @return maximum millis to wait for an idle subprocess */
+    public long getOutputIdleTimeout() {
+        return mOutputIdleTimeout;
+    }
+
+    public void setOutputIdleTimeout(long outputIdleTimeout) {
+        mOutputIdleTimeout = outputIdleTimeout;
+    }
+
+    /**
+     * Adds a java property.
+     *
+     * @param name a property name.
+     * @param value a property value.
+     */
+    public void addJavaProperty(final String name, final String value) {
+        mJavaProperties.put(name, value);
+    }
+
+    /**
+     * Returns a {@link Map} object containing all Java properties.
+     *
+     * @return unmodifiable map of all runner properties.
+     */
+    public Map<String, String> getJavaProperties() {
+        return Collections.unmodifiableMap(mJavaProperties);
+    }
+
+    public String getContextFilePattern() {
+        return mContextFilePattern;
+    }
+
+    /** Adds a file path to append to the context file. */
+    public void addExtraContextFile(String path) {
+        mExtraContextFiles.add(path);
+    }
+
+    /** @return list of additional file paths to append to context file */
+    public List<String> getExtraContextFiles() {
+        return Collections.unmodifiableList(mExtraContextFiles);
+    }
+
+    public String getRetryCommandLine() {
+        return mRetryCommandLine;
+    }
+
+    public String getLogLevel() {
+        return mLogLevel;
+    }
+
+    public List<TradefedConfigObject> getTradefedConfigObjects() {
+        return Collections.unmodifiableList(mTradefedConfigObjects);
+    }
+
+    /**
+     * Adds a {@link TradefedConfigObject}.
+     *
+     * @param obj a {@link TradefedConfigObject}.
+     */
+    @VisibleForTesting
+    void addTradefedConfigObject(TradefedConfigObject obj) {
+        mTradefedConfigObjects.add(obj);
+    }
+
+    public static TestEnvironment fromJson(JSONObject json) throws JSONException {
+        TestEnvironment obj = new TestEnvironment();
+        final JSONArray envVars = json.optJSONArray("env_vars");
+        if (envVars != null) {
+            for (int i = 0; i < envVars.length(); i++) {
+                final JSONObject envVar = envVars.getJSONObject(i);
+                obj.addEnvVar(envVar.getString("key"), envVar.getString("value"));
+            }
+        } else {
+            CLog.w("env_vars is null");
+        }
+        final JSONArray javaProperties = json.optJSONArray("java_properties");
+        if (javaProperties != null) {
+            for (int i = 0; i < javaProperties.length(); i++) {
+                final JSONObject javaProperty = javaProperties.getJSONObject(i);
+                obj.addJavaProperty(javaProperty.getString("key"), javaProperty.getString("value"));
+            }
+        } else {
+            CLog.w("java_properties is null");
+        }
+        final JSONArray scripts = json.optJSONArray("setup_scripts");
+        if (scripts != null) {
+            for (int i = 0; i < scripts.length(); i++) {
+                obj.addSetupScripts(scripts.getString(i));
+            }
+        } else {
+            CLog.w("setup_scripts is null");
+        }
+        final JSONArray patterns = json.optJSONArray("output_file_patterns");
+        if (patterns != null) {
+            for (int i = 0; i < patterns.length(); i++) {
+                obj.addOutputFilePattern(patterns.getString(i));
+            }
+        } else {
+            CLog.w("output_file_patterns is null");
+        }
+        final String url = json.optString("output_file_upload_url");
+        if (url != null) {
+            obj.setOutputFileUploadUrl(url);
+        } else {
+            CLog.w("output_file_upload_url is null");
+        }
+        obj.mUseSubprocessReporting = json.optBoolean("use_subprocess_reporting");
+        obj.mOutputIdleTimeout = json.optLong("output_idle_timeout_millis", 0L);
+        obj.mContextFilePattern = json.optString("context_file_pattern");
+        JSONArray extraContextFiles = json.optJSONArray("extra_context_files");
+        if (extraContextFiles != null) {
+            for (int i = 0; i < extraContextFiles.length(); i++) {
+                obj.addExtraContextFile(extraContextFiles.getString(i));
+            }
+        } else {
+            CLog.w("extra_context_files is null");
+        }
+        obj.mRetryCommandLine = json.optString("retry_command_line");
+        obj.mLogLevel = json.optString("log_level");
+        final JSONArray arr = json.optJSONArray("tradefed_config_objects");
+        if (arr != null) {
+            obj.mTradefedConfigObjects.addAll(TradefedConfigObject.fromJsonArray(arr));
+        }
+        return obj;
+    }
+}
diff --git a/src/com/android/tradefed/cluster/TestGroupStatus.java b/src/com/android/tradefed/cluster/TestGroupStatus.java
new file mode 100644
index 0000000..3497f06
--- /dev/null
+++ b/src/com/android/tradefed/cluster/TestGroupStatus.java
@@ -0,0 +1,92 @@
+/*
+ * Copyright (C) 2019 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.cluster;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+/**
+ * A class to store status of a test group.
+ *
+ * <p>A test group corresponds to a group of test cases reported under a same test run name.
+ */
+public class TestGroupStatus {
+
+    private String mName;
+    private int mTotalTestCount;
+    private int mCompletedTestCount;
+    private int mFailedTestCount;
+    private boolean mIsComplete;
+    private long mElapsedTime;
+    private String mFailureMessage;
+
+    public TestGroupStatus(
+            final String name,
+            final int totalTestCount,
+            final int completedTestCount,
+            final int failedTestCount,
+            final boolean isComplete,
+            final long elapsedTime,
+            final String failureMessage) {
+        mName = name;
+        mTotalTestCount = totalTestCount;
+        mCompletedTestCount = completedTestCount;
+        mFailedTestCount = failedTestCount;
+        mIsComplete = isComplete;
+        mElapsedTime = elapsedTime;
+        mFailureMessage = failureMessage;
+    }
+
+    public String getName() {
+        return mName;
+    }
+
+    public int getTotalTestCount() {
+        return mTotalTestCount;
+    }
+
+    public int getCompletedTestCount() {
+        return mCompletedTestCount;
+    }
+
+    public int getFailedTestCount() {
+        return mFailedTestCount;
+    }
+
+    public boolean isComplete() {
+        return mIsComplete;
+    }
+
+    public long getElapsedTime() {
+        return mElapsedTime;
+    }
+
+    public String getFailureMessage() {
+        return mFailureMessage;
+    }
+
+    public JSONObject toJSON() throws JSONException {
+        final JSONObject json = new JSONObject();
+        json.put("name", mName);
+        json.put("total_test_count", mTotalTestCount);
+        json.put("completed_test_count", mCompletedTestCount);
+        json.put("failed_test_count", mFailedTestCount);
+        json.put("is_complete", mIsComplete);
+        json.put("elapsed_time", mElapsedTime);
+        json.put("failure_message", mFailureMessage);
+        return json;
+    }
+}
diff --git a/src/com/android/tradefed/cluster/TestOutputUploader.java b/src/com/android/tradefed/cluster/TestOutputUploader.java
new file mode 100644
index 0000000..c00f1c3
--- /dev/null
+++ b/src/com/android/tradefed/cluster/TestOutputUploader.java
@@ -0,0 +1,127 @@
+/*
+ * Copyright (C) 2019 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.cluster;
+
+import com.android.loganalysis.util.ArrayUtil;
+import com.android.tradefed.log.LogUtil.CLog;
+import com.android.tradefed.util.CommandResult;
+import com.android.tradefed.util.CommandStatus;
+import com.android.tradefed.util.FileUtil;
+import com.android.tradefed.util.IRunUtil;
+import com.android.tradefed.util.RunUtil;
+
+import com.google.common.annotations.VisibleForTesting;
+
+import java.io.File;
+import java.io.IOException;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.util.List;
+
+/** A class to upload test output files to GCS/HTTP. */
+public class TestOutputUploader {
+
+    static final long UPLOAD_TIMEOUT_MS = 30 * 60 * 1000;
+    static final long RETRY_INTERVAL_MS = 10 * 1000;
+    static final int MAX_RETRY_COUNT = 2;
+    static final String FILE_PROTOCOL = "file";
+    static final String GCS_PROTOCOL = "gs";
+    static final String HTTP_PROTOCOL = "http";
+    static final String HTTPS_PROTOCOL = "https";
+
+    private String mUploadUrl = null;
+    private String mProtocol = null;
+    private IRunUtil mRunUtil = null;
+
+    public void setUploadUrl(final String url) throws MalformedURLException {
+        mUploadUrl = url;
+        URL urlObj = new URL(url);
+        mProtocol = urlObj.getProtocol();
+    }
+
+    public String uploadFile(File file, final String destinationPath) throws IOException {
+        String uploadUrl = mUploadUrl;
+        if (!mUploadUrl.endsWith("/")) {
+            uploadUrl += "/";
+        }
+        if (destinationPath != null) {
+            uploadUrl += destinationPath;
+        }
+        CLog.i("Uploading %s to %s", file.getAbsolutePath(), uploadUrl);
+        if (FILE_PROTOCOL.equals(mProtocol)) {
+            final File dir = new File(new URL(uploadUrl).getPath());
+            if (!dir.exists()) {
+                dir.mkdirs();
+            }
+            final File destFile = new File(dir, file.getName());
+            FileUtil.copyFile(file, destFile);
+        } else {
+            final List<String> cmdArgs = buildUploadCommandArgs(file, uploadUrl);
+            final CommandResult result =
+                    getRunUtil()
+                            .runTimedCmdRetry(
+                                    UPLOAD_TIMEOUT_MS,
+                                    RETRY_INTERVAL_MS,
+                                    MAX_RETRY_COUNT,
+                                    cmdArgs.toArray(new String[0]));
+            if (!result.getStatus().equals(CommandStatus.SUCCESS)) {
+                final String msg =
+                        String.format(
+                                "failed to upload %s: command status=%s",
+                                file.getAbsolutePath(), result.getStatus());
+                CLog.e(msg);
+                CLog.e("stdout:\n'''\n%s'''\n", result.getStdout());
+                CLog.d("stderr:\n'''\n%s'''\n", result.getStderr());
+                throw new RuntimeException(msg);
+            }
+        }
+        String baseUrl = uploadUrl;
+        if (!baseUrl.endsWith("/")) {
+            baseUrl += "/";
+        }
+        return baseUrl + file.getName();
+    }
+
+    private List<String> buildUploadCommandArgs(File file, String uploadUrl) {
+        if (mUploadUrl == null) {
+            throw new IllegalStateException("upload url is not set");
+        }
+        switch (mProtocol) {
+            case GCS_PROTOCOL:
+                return ArrayUtil.list("gsutil", "cp", file.getAbsolutePath(), uploadUrl);
+            case HTTP_PROTOCOL:
+            case HTTPS_PROTOCOL:
+                // Add -L option to handle redirect.
+                return ArrayUtil.list(
+                        "curl",
+                        "-X",
+                        "POST",
+                        "-F file=@" + file.getAbsolutePath(),
+                        "-fL",
+                        uploadUrl);
+        }
+        throw new IllegalArgumentException(
+                String.format("Protocol '%s' is not supported", mProtocol));
+    }
+
+    @VisibleForTesting
+    IRunUtil getRunUtil() {
+        if (mRunUtil == null) {
+            mRunUtil = RunUtil.getDefault();
+        }
+        return mRunUtil;
+    }
+}
diff --git a/src/com/android/tradefed/cluster/TestResource.java b/src/com/android/tradefed/cluster/TestResource.java
new file mode 100644
index 0000000..75f209b
--- /dev/null
+++ b/src/com/android/tradefed/cluster/TestResource.java
@@ -0,0 +1,61 @@
+/*
+ * Copyright (C) 2019 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.cluster;
+
+import java.util.ArrayList;
+import java.util.List;
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+/** A class to model a TestResource message returned by TFC API. */
+public class TestResource {
+
+    private final String mName;
+    private final String mUrl;
+
+    TestResource(final String name, final String url) {
+        mName = name;
+        mUrl = url;
+    }
+
+    public String getName() {
+        return mName;
+    }
+
+    public String getUrl() {
+        return mUrl;
+    }
+
+    public JSONObject toJson() throws JSONException {
+        final JSONObject json = new JSONObject();
+        json.put("name", mName);
+        json.put("url", mUrl);
+        return json;
+    }
+
+    public static TestResource fromJson(JSONObject json) {
+        return new TestResource(json.optString("name"), json.optString("url"));
+    }
+
+    public static List<TestResource> fromJsonArray(JSONArray jsonArray) throws JSONException {
+        final List<TestResource> objs = new ArrayList<>();
+        for (int i = 0; i < jsonArray.length(); i++) {
+            objs.add(TestResource.fromJson(jsonArray.getJSONObject(i)));
+        }
+        return objs;
+    }
+}
diff --git a/src/com/android/tradefed/cluster/TestResourceDownloader.java b/src/com/android/tradefed/cluster/TestResourceDownloader.java
new file mode 100644
index 0000000..5e485d7
--- /dev/null
+++ b/src/com/android/tradefed/cluster/TestResourceDownloader.java
@@ -0,0 +1,99 @@
+/*
+ * Copyright (C) 2019 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.cluster;
+
+import com.android.loganalysis.util.ArrayUtil;
+import com.android.tradefed.log.LogUtil.CLog;
+import com.android.tradefed.util.CommandResult;
+import com.android.tradefed.util.CommandStatus;
+import com.android.tradefed.util.FileUtil;
+import com.android.tradefed.util.IRunUtil;
+import com.android.tradefed.util.RunUtil;
+import java.io.File;
+import java.io.IOException;
+import java.net.URL;
+import java.util.List;
+
+/** A class to download test resource files from file system/GCS/HTTP. */
+public class TestResourceDownloader {
+
+    private static final long DOWNLOAD_TIMEOUT_MS = 30 * 60 * 1000;
+    private static final long RETRY_INTERVAL_MS = 10 * 1000;
+    private static final int MAX_RETRY_COUNT = 2;
+
+    private final File mRootDir;
+    private IRunUtil mRunUtil = null;
+
+    public TestResourceDownloader(final File rootDir) {
+        mRootDir = rootDir;
+    }
+
+    public File download(TestResource resource) throws IOException {
+        final URL url = new URL(resource.getUrl());
+        final String protocol = url.getProtocol();
+        final File dest = new File(mRootDir, resource.getName());
+        final File parent = dest.getParentFile();
+        if (!parent.exists()) {
+            parent.mkdirs();
+        }
+        if ("file".equals(protocol)) {
+            final File src = new File(resource.getUrl().substring(6));
+            FileUtil.hardlinkFile(src, dest);
+            return dest;
+        }
+
+        final List<String> cmdArgs = buildDownloadCommandArgs(url, dest);
+        final CommandResult result =
+                getRunUtil()
+                        .runTimedCmdRetry(
+                                DOWNLOAD_TIMEOUT_MS,
+                                RETRY_INTERVAL_MS,
+                                MAX_RETRY_COUNT + 1,
+                                cmdArgs.toArray(new String[0]));
+        if (!result.getStatus().equals(CommandStatus.SUCCESS)) {
+            final String msg =
+                    String.format(
+                            "Failed to download %s: command status=%s", url, result.getStatus());
+            CLog.e(msg);
+            CLog.e("stdout:\n'''\n%s'''\n", result.getStdout());
+            CLog.d("stderr:\n'''\n%s'''\n", result.getStderr());
+            throw new RuntimeException(msg);
+        }
+        return dest;
+    }
+
+    /** Build a list of command line arguments to download a file. */
+    private List<String> buildDownloadCommandArgs(URL url, File file) {
+        final String protocol = url.getProtocol();
+        if ("gs".equals(protocol)) {
+            // FIXME: Check whether gsutil is available on a host.
+            return ArrayUtil.list("gsutil", "cp", url.toString(), file.getAbsolutePath());
+        }
+        if ("http".equals(protocol) || "https".equals(protocol)) {
+            // FIXME: Check whether curl is available on a host.
+            // Add -L option to handle redirect.
+            return ArrayUtil.list("curl", "-o", file.getAbsolutePath(), "-fL", url.toString());
+        }
+        throw new UnsupportedOperationException("protocol " + protocol + " is not supported");
+    }
+
+    IRunUtil getRunUtil() {
+        if (mRunUtil == null) {
+            mRunUtil = new RunUtil();
+        }
+        return mRunUtil;
+    }
+}
diff --git a/src/com/android/tradefed/cluster/TradefedConfigObject.java b/src/com/android/tradefed/cluster/TradefedConfigObject.java
new file mode 100644
index 0000000..ffc5475
--- /dev/null
+++ b/src/com/android/tradefed/cluster/TradefedConfigObject.java
@@ -0,0 +1,100 @@
+/*
+ * Copyright (C) 2019 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.cluster;
+
+import com.android.tradefed.config.Configuration;
+import com.android.tradefed.result.ITestInvocationListener;
+import com.android.tradefed.targetprep.ITargetPreparer;
+import com.android.tradefed.util.MultiMap;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * A class to model a TradefedConfigObject message of TFC API.
+ *
+ * <p>A TradefedConfigObject message is a part of a TestEnvironment message and used to pass down
+ * extra {@link ITargetPreparer} or {@link ITestInvocationListener} to be added to a cluster command
+ * launcher config.
+ */
+public class TradefedConfigObject {
+
+    /**
+     * A list of configuration object types which can be injected to a cluster command config.
+     *
+     * <p>This must be a subset of configuration object types defined in {@link Configuration}.
+     */
+    public static enum Type {
+        UNKNOWN,
+        TARGET_PREPARER,
+        RESULT_REPORTER
+    }
+
+    private final Type mType;
+    private final String mClassName;
+    private final MultiMap<String, String> mOptionValues;
+
+    TradefedConfigObject(Type type, String className, MultiMap<String, String> optionValues) {
+        mType = type;
+        mClassName = className;
+        mOptionValues = optionValues;
+    }
+
+    public Type getType() {
+        return mType;
+    }
+
+    public String getClassName() {
+        return mClassName;
+    }
+
+    public MultiMap<String, String> getOptionValues() {
+        return mOptionValues;
+    }
+
+    public static TradefedConfigObject fromJson(JSONObject json) throws JSONException {
+        MultiMap<String, String> optionValues = new MultiMap<>();
+        JSONArray arr = json.optJSONArray("option_values");
+        if (arr != null) {
+            for (int i = 0; i < arr.length(); i++) {
+                JSONObject pair = arr.getJSONObject(i);
+                String key = pair.getString("key");
+                JSONArray valueArr = pair.optJSONArray("values");
+                if (valueArr == null) {
+                    optionValues.put(key, null);
+                } else {
+                    for (int j = 0; j < valueArr.length(); j++) {
+                        optionValues.put(key, valueArr.getString(j));
+                    }
+                }
+            }
+        }
+        Type type = Type.valueOf(json.optString("type", Type.UNKNOWN.name()));
+        return new TradefedConfigObject(type, json.getString("class_name"), optionValues);
+    }
+
+    public static List<TradefedConfigObject> fromJsonArray(JSONArray arr) throws JSONException {
+        List<TradefedConfigObject> objs = new ArrayList<>();
+        for (int i = 0; i < arr.length(); i++) {
+            objs.add(fromJson(arr.getJSONObject(i)));
+        }
+        return objs;
+    }
+}
diff --git a/src/com/android/tradefed/util/IRestApiHelper.java b/src/com/android/tradefed/util/IRestApiHelper.java
new file mode 100644
index 0000000..12d2b07
--- /dev/null
+++ b/src/com/android/tradefed/util/IRestApiHelper.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright (C) 2019 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.util;
+
+import com.google.api.client.http.HttpResponse;
+
+import org.json.JSONObject;
+
+import java.io.IOException;
+import java.util.Map;
+
+/** A helper interface for performing REST API calls. */
+public interface IRestApiHelper {
+
+    /**
+     * Executes an API request.
+     *
+     * @param method a HTTP method of the request
+     * @param uriParts URL encoded URI parts to be used to construct the request URI.
+     * @param options unencoded parameter names and values used to construct the query string
+     * @param data data to be sent with the request
+     * @return a HttpResponse object
+     * @throws IOException
+     */
+    public HttpResponse execute(
+            String method, String[] uriParts, Map<String, Object> options, JSONObject data)
+            throws IOException;
+}
diff --git a/src/com/android/tradefed/util/RestApiHelper.java b/src/com/android/tradefed/util/RestApiHelper.java
new file mode 100644
index 0000000..7970dc4
--- /dev/null
+++ b/src/com/android/tradefed/util/RestApiHelper.java
@@ -0,0 +1,148 @@
+/*
+ * Copyright (C) 2019 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.util;
+
+import com.google.api.client.googleapis.auth.oauth2.GoogleCredential;
+import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport;
+import com.google.api.client.http.ByteArrayContent;
+import com.google.api.client.http.GenericUrl;
+import com.google.api.client.http.HttpContent;
+import com.google.api.client.http.HttpRequest;
+import com.google.api.client.http.HttpRequestFactory;
+import com.google.api.client.http.HttpResponse;
+import com.google.api.client.http.HttpTransport;
+import com.google.api.client.json.JsonFactory;
+import com.google.api.client.json.JsonObjectParser;
+import com.google.api.client.json.jackson2.JacksonFactory;
+import com.google.common.annotations.VisibleForTesting;
+
+import org.json.JSONObject;
+
+import java.io.File;
+import java.io.IOException;
+import java.security.GeneralSecurityException;
+import java.util.Collection;
+import java.util.Map;
+
+/** A helper class for performing REST API calls. */
+public class RestApiHelper implements IRestApiHelper {
+
+    protected static final JsonFactory JSON_FACTORY = JacksonFactory.getDefaultInstance();
+    protected static final String JSON_MIME = "application/json";
+
+    private HttpRequestFactory mRequestFactory;
+    private String mBaseUri;
+
+    /**
+     * Creates an API helper instance with the given information.
+     *
+     * @param baseUri the base URI of API
+     * @param requestFactory the factory to use when creating {@link HttpRequest}s.
+     */
+    public RestApiHelper(HttpRequestFactory requestFactory, String baseUri) {
+        mRequestFactory = requestFactory;
+        // Make sure the uri ends with a slash to avoid GenericUrl weirdness later
+        mBaseUri = baseUri.endsWith("/") ? baseUri : baseUri + "/";
+    }
+
+    /**
+     * Creates an API helper instance which uses a {@link GoogleCredential} for authentication.
+     *
+     * @param baseUri the base URI of the API
+     * @param serviceAccount the name of the service account to use
+     * @param keyFile the service account key file
+     * @param scopes the collection of OAuth scopes to use with the service account
+     * @throws GeneralSecurityException
+     * @throws IOException
+     */
+    @Deprecated
+    public static RestApiHelper newInstanceWithGoogleCredential(
+            String baseUri, String serviceAccount, File keyFile, Collection<String> scopes)
+            throws GeneralSecurityException, IOException {
+
+        HttpTransport transport = GoogleNetHttpTransport.newTrustedTransport();
+        GoogleCredential credential =
+                GoogleApiClientUtil.createCredentialFromP12File(serviceAccount, keyFile, scopes);
+        HttpRequestFactory requestFactory = transport.createRequestFactory(credential);
+
+        return new RestApiHelper(requestFactory, baseUri);
+    }
+
+    /**
+     * Creates an API helper instance which uses a {@link GoogleCredential} for authentication.
+     *
+     * @param baseUri the base URI of the API
+     * @param jsonKeyFile the service account json key file
+     * @param scopes the collection of OAuth scopes to use with the service account
+     * @throws GeneralSecurityException
+     * @throws IOException
+     */
+    public static RestApiHelper newInstanceWithGoogleCredential(
+            String baseUri, File jsonKeyFile, Collection<String> scopes)
+            throws GeneralSecurityException, IOException {
+        HttpTransport transport = GoogleNetHttpTransport.newTrustedTransport();
+        GoogleCredential credential =
+                GoogleApiClientUtil.createCredentialFromJsonKeyFile(jsonKeyFile, scopes);
+        HttpRequestFactory requestFactory = transport.createRequestFactory(credential);
+        return new RestApiHelper(requestFactory, baseUri);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public HttpResponse execute(
+            String method, String[] uriParts, Map<String, Object> options, JSONObject data)
+            throws IOException {
+        HttpContent content = null;
+        if (data != null) {
+            content = new ByteArrayContent(JSON_MIME, data.toString().getBytes());
+        }
+        final GenericUrl uri = buildQueryUri(uriParts, options);
+        final HttpRequest request = getRequestFactory().buildRequest(method, uri, content);
+        request.setParser(new JsonObjectParser(JSON_FACTORY));
+        return request.execute();
+    }
+
+    /**
+     * Returns the HttpRequestFactory.
+     *
+     * <p>Exposed for testing.
+     */
+    @VisibleForTesting
+    public HttpRequestFactory getRequestFactory() {
+        return mRequestFactory;
+    }
+
+    /**
+     * Construct a URI for a API call with given URI parts and options. uriParts should be
+     * URL-encoded already, while options should be unencoded Strings.
+     */
+    GenericUrl buildQueryUri(String[] uriParts, Map<String, Object> options) {
+        final GenericUrl uri = new GenericUrl(mBaseUri);
+        for (int i = 0; i < uriParts.length; ++i) {
+            uri.appendRawPath(uriParts[i]);
+            // Don't add a trailing slash
+            if (i + 1 < uriParts.length) {
+                uri.appendRawPath("/");
+            }
+        }
+
+        if (options != null) {
+            uri.putAll(options);
+        }
+
+        return uri;
+    }
+}
diff --git a/tests/Android.bp b/tests/Android.bp
index c0dd39c..7d1334a 100644
--- a/tests/Android.bp
+++ b/tests/Android.bp
@@ -35,6 +35,7 @@
         "tradefed",
         "libprotobuf-java-full",
         "truth-prebuilt",
+        "loganalysis",
     ],
 
     manifest: "MANIFEST.mf",
diff --git a/tests/res/testdata/tradefed-prebuilt-cts-8.0_r21.jar b/tests/res/testdata/tradefed-prebuilt-cts-8.0_r21.jar
new file mode 100644
index 0000000..bdc8559
--- /dev/null
+++ b/tests/res/testdata/tradefed-prebuilt-cts-8.0_r21.jar
Binary files differ
diff --git a/tests/src/com/android/tradefed/FuncTests.java b/tests/src/com/android/tradefed/FuncTests.java
index 76e329d..81430b9 100644
--- a/tests/src/com/android/tradefed/FuncTests.java
+++ b/tests/src/com/android/tradefed/FuncTests.java
@@ -16,6 +16,8 @@
 package com.android.tradefed;
 
 import com.android.tradefed.build.FileDownloadCacheFuncTest;
+import com.android.tradefed.cluster.ClusterCommandLauncherFuncTest;
+import com.android.tradefed.cluster.ClusterEventUploaderFuncTest;
 import com.android.tradefed.command.CommandSchedulerFuncTest;
 import com.android.tradefed.command.remote.RemoteManagerFuncTest;
 import com.android.tradefed.device.metric.DeviceMetricDataFuncTest;
@@ -34,6 +36,9 @@
 @SuiteClasses({
     // build
     FileDownloadCacheFuncTest.class,
+    // cluster
+    ClusterCommandLauncherFuncTest.class,
+    ClusterEventUploaderFuncTest.class,
     // command
     CommandSchedulerFuncTest.class,
     // command.remote
diff --git a/tests/src/com/android/tradefed/UnitTests.java b/tests/src/com/android/tradefed/UnitTests.java
index 378d513..dbb8638 100644
--- a/tests/src/com/android/tradefed/UnitTests.java
+++ b/tests/src/com/android/tradefed/UnitTests.java
@@ -16,6 +16,17 @@
 
 package com.android.tradefed;
 
+import com.android.tradefed.cluster.ClusterCommandEventTest;
+import com.android.tradefed.cluster.ClusterCommandLauncherTest;
+import com.android.tradefed.cluster.ClusterCommandSchedulerTest;
+import com.android.tradefed.cluster.ClusterCommandTest;
+import com.android.tradefed.cluster.ClusterDeviceMonitorTest;
+import com.android.tradefed.cluster.ClusterEventUploaderTest;
+import com.android.tradefed.cluster.ClusterHostUtilTest;
+import com.android.tradefed.cluster.ClusterLogSaverTest;
+import com.android.tradefed.cluster.SubprocessConfigBuilderTest;
+import com.android.tradefed.cluster.SubprocessReportingHelperTest;
+import com.android.tradefed.cluster.TestOutputUploaderTest;
 import com.android.tradefed.build.AppDeviceBuildInfoTest;
 import com.android.tradefed.build.BootstrapBuildProviderTest;
 import com.android.tradefed.build.BuildInfoTest;
@@ -361,6 +372,7 @@
 import com.android.tradefed.util.testmapping.TestInfoTest;
 import com.android.tradefed.util.testmapping.TestMappingTest;
 import com.android.tradefed.util.zip.MergedZipEntryCollectionTest;
+import com.android.tradefed.util.RestApiHelperTest;
 
 import org.junit.runner.RunWith;
 import org.junit.runners.Suite;
@@ -390,6 +402,19 @@
     // clearcut
     ClearcutClientTest.class,
 
+    // cluster
+    ClusterCommandEventTest.class,
+    ClusterCommandLauncherTest.class,
+    ClusterCommandSchedulerTest.class,
+    ClusterCommandTest.class,
+    ClusterDeviceMonitorTest.class,
+    ClusterEventUploaderTest.class,
+    ClusterHostUtilTest.class,
+    ClusterLogSaverTest.class,
+    SubprocessConfigBuilderTest.class,
+    SubprocessReportingHelperTest.class,
+    TestOutputUploaderTest.class,
+
     // command
     CommandFileParserTest.class,
     CommandFileWatcherTest.class,
@@ -776,6 +801,7 @@
     QuotationAwareTokenizerTest.class,
     RegexTrieTest.class,
     RemoteZipTest.class,
+    RestApiHelperTest.class,
     RunUtilTest.class,
     SerializationUtilTest.class,
     ShellOutputReceiverStreamTest.class,
diff --git a/tests/src/com/android/tradefed/cluster/ClusterCommandEventTest.java b/tests/src/com/android/tradefed/cluster/ClusterCommandEventTest.java
new file mode 100644
index 0000000..4e1e202
--- /dev/null
+++ b/tests/src/com/android/tradefed/cluster/ClusterCommandEventTest.java
@@ -0,0 +1,74 @@
+/*
+ * Copyright (C) 2019 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.cluster;
+
+import org.json.JSONObject;
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.util.HashSet;
+import java.util.Set;
+
+/** Unit tests for {@link ClusterCommandEvent}. */
+@RunWith(JUnit4.class)
+public class ClusterCommandEventTest {
+
+    @Test
+    public void testToJSON() throws Exception {
+        Set<String> device_serials = new HashSet<>();
+        device_serials.add("s1");
+        ClusterCommandEvent event =
+                new ClusterCommandEvent.Builder()
+                        .setType(ClusterCommandEvent.Type.InvocationStarted)
+                        .setAttemptId("attempt_id")
+                        .setCommandTaskId("task_id")
+                        .setDeviceSerials(device_serials)
+                        .setHostName("host")
+                        .setTimestamp(100000)
+                        .build();
+        JSONObject obj = event.toJSON();
+        Assert.assertEquals("InvocationStarted", obj.getString("type"));
+        Assert.assertEquals("task_id", obj.getString("task_id"));
+        Assert.assertEquals("attempt_id", obj.getString("attempt_id"));
+        Assert.assertEquals("host", obj.getString("hostname"));
+        Assert.assertEquals(100, obj.getLong("time"));
+        Assert.assertEquals("s1", obj.getString("device_serial"));
+        Assert.assertEquals(1, obj.getJSONArray("device_serials").length());
+        Assert.assertEquals("s1", obj.getJSONArray("device_serials").getString(0));
+    }
+
+    @Test
+    public void testToJSON_noDeviceSerial() throws Exception {
+        ClusterCommandEvent event =
+                new ClusterCommandEvent.Builder()
+                        .setType(ClusterCommandEvent.Type.InvocationStarted)
+                        .setAttemptId("attempt_id")
+                        .setCommandTaskId("task_id")
+                        .setHostName("host")
+                        .setTimestamp(100000)
+                        .build();
+        JSONObject obj = event.toJSON();
+        Assert.assertEquals("InvocationStarted", obj.getString("type"));
+        Assert.assertEquals("task_id", obj.getString("task_id"));
+        Assert.assertEquals("attempt_id", obj.getString("attempt_id"));
+        Assert.assertEquals("host", obj.getString("hostname"));
+        Assert.assertEquals(100, obj.getLong("time"));
+        Assert.assertFalse(obj.has("device_serial"));
+        Assert.assertEquals(0, obj.getJSONArray("device_serials").length());
+    }
+}
diff --git a/tests/src/com/android/tradefed/cluster/ClusterCommandLauncherFuncTest.java b/tests/src/com/android/tradefed/cluster/ClusterCommandLauncherFuncTest.java
new file mode 100644
index 0000000..1cbceba
--- /dev/null
+++ b/tests/src/com/android/tradefed/cluster/ClusterCommandLauncherFuncTest.java
@@ -0,0 +1,100 @@
+/*
+ * Copyright (C) 2019 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.cluster;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.verify;
+
+import com.android.tradefed.config.Configuration;
+import com.android.tradefed.config.ConfigurationException;
+import com.android.tradefed.config.IConfiguration;
+import com.android.tradefed.config.OptionSetter;
+import com.android.tradefed.device.DeviceNotAvailableException;
+import com.android.tradefed.invoker.IInvocationContext;
+import com.android.tradefed.invoker.InvocationContext;
+import com.android.tradefed.metrics.proto.MetricMeasurement;
+import com.android.tradefed.result.ITestInvocationListener;
+import com.android.tradefed.result.TestDescription;
+import com.android.tradefed.util.FileUtil;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.Spy;
+import org.mockito.junit.MockitoJUnitRunner;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.HashMap;
+
+/** Functional tests for {@link ClusterCommandLauncher}. */
+@RunWith(MockitoJUnitRunner.class)
+public class ClusterCommandLauncherFuncTest {
+
+    private static final String LEGACY_TRADEFED_JAR = "/testdata/tradefed-prebuilt-cts-8.0_r21.jar";
+    private static final String LEGACY_TRADEFED_COMMAND =
+            "host --null-device --class com.android.tradefed.device.DeviceDiagTest";
+
+    private File mRootDir;
+    private IConfiguration mConfiguration;
+    private IInvocationContext mInvocationContext;
+    private OptionSetter mOptionSetter;
+
+    @Spy private ClusterCommandLauncher mLauncher;
+    @Mock private ITestInvocationListener mListener;
+
+    @Before
+    public void setUp() throws Exception {
+        mRootDir = FileUtil.createTempDir(getClass().getName() + "_RootDir");
+        mConfiguration = new Configuration("name", "description");
+        mConfiguration.getCommandOptions().setInvocationTimeout(60_000L); // 1 minute
+        mInvocationContext = new InvocationContext();
+        mLauncher.setConfiguration(mConfiguration);
+        mLauncher.setInvocationContext(mInvocationContext);
+        mOptionSetter = new OptionSetter(mLauncher);
+        mOptionSetter.setOptionValue("cluster:root-dir", mRootDir.getAbsolutePath());
+        mOptionSetter.setOptionValue("cluster:env-var", "TF_WORK_DIR", mRootDir.getAbsolutePath());
+    }
+
+    @After
+    public void tearDown() {
+        FileUtil.recursiveDelete(mRootDir);
+    }
+
+    @Test
+    public void testRun_withLegacyTradefed()
+            throws IOException, ConfigurationException, DeviceNotAvailableException {
+        File tfJar = new File(mRootDir, "tradefed.jar");
+        FileUtil.writeToFile(getClass().getResourceAsStream(LEGACY_TRADEFED_JAR), tfJar);
+        mOptionSetter.setOptionValue("cluster:env-var", "TF_PATH", mRootDir.getAbsolutePath());
+        mOptionSetter.setOptionValue("cluster:use-subprocess-reporting", "true");
+        mOptionSetter.setOptionValue("cluster:command-line", LEGACY_TRADEFED_COMMAND);
+
+        mLauncher.run(mListener);
+
+        HashMap<String, MetricMeasurement.Metric> emptyMap = new HashMap<>();
+        verify(mListener).testRunStarted(anyString(), anyInt(), anyInt(), anyLong());
+        verify(mListener).testStarted(any(TestDescription.class), anyLong());
+        verify(mListener).testEnded(any(TestDescription.class), anyLong(), eq(emptyMap));
+        verify(mListener).testRunEnded(anyLong(), eq(emptyMap));
+    }
+}
diff --git a/tests/src/com/android/tradefed/cluster/ClusterCommandLauncherTest.java b/tests/src/com/android/tradefed/cluster/ClusterCommandLauncherTest.java
new file mode 100644
index 0000000..ddb14ea
--- /dev/null
+++ b/tests/src/com/android/tradefed/cluster/ClusterCommandLauncherTest.java
@@ -0,0 +1,260 @@
+/*
+ * Copyright (C) 2019 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.cluster;
+
+import static org.junit.Assert.assertTrue;
+import static org.mockito.Mockito.when;
+
+import com.android.loganalysis.util.ArrayUtil;
+import com.android.tradefed.config.Configuration;
+import com.android.tradefed.config.ConfigurationException;
+import com.android.tradefed.config.IConfiguration;
+import com.android.tradefed.config.OptionSetter;
+import com.android.tradefed.device.DeviceNotAvailableException;
+import com.android.tradefed.device.ITestDevice;
+import com.android.tradefed.invoker.IInvocationContext;
+import com.android.tradefed.invoker.InvocationContext;
+import com.android.tradefed.result.ITestInvocationListener;
+import com.android.tradefed.util.CommandResult;
+import com.android.tradefed.util.CommandStatus;
+import com.android.tradefed.util.FileUtil;
+import com.android.tradefed.util.IRunUtil;
+import com.android.tradefed.util.SubprocessTestResultsParser;
+import com.android.tradefed.util.SystemUtil;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.mockito.Mockito;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+/** Unit tests for {@link ClusterCommandLauncherTest}. */
+@RunWith(JUnit4.class)
+public class ClusterCommandLauncherTest {
+
+    private static final String DEVICE_SERIAL = "device_serial";
+
+    private IRunUtil mMockRunUtil;
+    private SubprocessTestResultsParser mMockSubprocessTestResultsParser;
+    private ITestInvocationListener mMockListener;
+    private ITestDevice mMockTestDevice;
+    private File mTfPath;
+    private File mTfLibDir;
+    private File mRootDir;
+    private IConfiguration mConfiguration;
+    private IInvocationContext mInvocationContext;
+    private ClusterCommandLauncher mLauncher;
+    private OptionSetter mOptionSetter;
+
+    private File createTempDir(final String key) throws IOException {
+        return FileUtil.createTempDir(this.getClass().getName() + "_" + key);
+    }
+
+    private String[] asMatchers(String... strs) {
+        return Arrays.stream(strs).map(x -> Mockito.eq(x)).toArray(String[]::new);
+    }
+
+    @Before
+    public void setUp() throws Exception {
+        mMockRunUtil = Mockito.mock(IRunUtil.class);
+        mMockSubprocessTestResultsParser = Mockito.mock(SubprocessTestResultsParser.class);
+        mMockListener = Mockito.mock(ITestInvocationListener.class);
+        mMockTestDevice = Mockito.mock(ITestDevice.class);
+        Mockito.doReturn(DEVICE_SERIAL).when(mMockTestDevice).getSerialNumber();
+
+        mRootDir = createTempDir("RootDir");
+        mTfPath = new File(mRootDir, "TfPath");
+        mTfPath.mkdir();
+        mTfLibDir = new File(mRootDir, "TfLibDir");
+        mTfLibDir.mkdir();
+        mConfiguration = new Configuration("name", "description");
+        mConfiguration.getCommandOptions().setInvocationTimeout(10000L);
+        mInvocationContext = new InvocationContext();
+        mLauncher = Mockito.spy(ClusterCommandLauncher.class);
+        mLauncher.setConfiguration(mConfiguration);
+        mLauncher.setInvocationContext(mInvocationContext);
+        mOptionSetter = new OptionSetter(mLauncher);
+        mOptionSetter.setOptionValue("cluster:root-dir", mRootDir.getAbsolutePath());
+        mOptionSetter.setOptionValue("cluster:env-var", "TF_WORK_DIR", mRootDir.getAbsolutePath());
+    }
+
+    @After
+    public void tearDown() {
+        FileUtil.recursiveDelete(mRootDir);
+    }
+
+    @Test
+    public void testRun() throws DeviceNotAvailableException, ConfigurationException {
+        mInvocationContext.addAllocatedDevice("foo", mMockTestDevice);
+        final List<String> jars = new ArrayList<>();
+        jars.add(String.format("%s/*", mTfPath));
+        jars.add(String.format("%s/*", mTfLibDir));
+        final String classPath = ArrayUtil.join(":", jars);
+        final String tfPathValue =
+                String.format(
+                        "${TF_WORK_DIR}/%s:${TF_WORK_DIR}/%s",
+                        mTfPath.getName(), mTfLibDir.getName());
+        mOptionSetter.setOptionValue("cluster:env-var", "TF_PATH", tfPathValue);
+        mOptionSetter.setOptionValue("cluster:java-property", "FOO", "${TF_WORK_DIR}/foo");
+        mOptionSetter.setOptionValue("cluster:original-command-line", "original-command-line");
+        mOptionSetter.setOptionValue("cluster:command-line", "command-line");
+        final String expandedTfPathValue =
+                String.format("%s:%s", mTfPath.getAbsolutePath(), mTfLibDir.getAbsolutePath());
+        final CommandResult mockCommandResult = new CommandResult(CommandStatus.SUCCESS);
+        when(mMockRunUtil.runTimedCmd(
+                        Mockito.anyLong(),
+                        Mockito.<OutputStream>any(),
+                        Mockito.<OutputStream>any(),
+                        Mockito.<String[]>any()))
+                .thenReturn(mockCommandResult);
+        Mockito.when(mLauncher.getRunUtil()).thenReturn(mMockRunUtil);
+
+        mLauncher.run(mMockListener);
+
+        Mockito.verify(mMockRunUtil, Mockito.times(2)).setWorkingDir(mRootDir);
+        Mockito.verify(mMockRunUtil).unsetEnvVariable("TF_GLOBAL_CONFIG");
+        Mockito.verify(mMockRunUtil).setEnvVariable("TF_WORK_DIR", mRootDir.getAbsolutePath());
+        Mockito.verify(mMockRunUtil).setEnvVariable("TF_PATH", expandedTfPathValue);
+        Mockito.verify(mMockRunUtil)
+                .runTimedCmd(
+                        Mockito.eq(10000L),
+                        Mockito.<OutputStream>any(),
+                        Mockito.<OutputStream>any(),
+                        asMatchers(
+                                new String[] {
+                                    SystemUtil.getRunningJavaBinaryPath().getAbsolutePath(),
+                                    "-cp",
+                                    classPath,
+                                    "-DFOO=" + mRootDir.getAbsolutePath() + "/foo",
+                                    "com.android.tradefed.command.CommandRunner",
+                                    "command-line",
+                                    "--serial",
+                                    DEVICE_SERIAL
+                                }));
+        assertTrue(new File(mRootDir, "original-command-line.xml").exists());
+    }
+
+    @Test
+    public void testRun_withSetupScripts()
+            throws DeviceNotAvailableException, ConfigurationException {
+        mInvocationContext.addAllocatedDevice("foo", mMockTestDevice);
+        final String classpath = String.format("%s/*", mTfPath);
+        mOptionSetter.setOptionValue("cluster:env-var", "TF_PATH", mTfPath.getAbsolutePath());
+        mOptionSetter.setOptionValue("cluster:env-var", "FOO", "foo");
+        mOptionSetter.setOptionValue("cluster:env-var", "BAR", "bar");
+        mOptionSetter.setOptionValue("cluster:env-var", "ZZZ", "zzz");
+        mOptionSetter.setOptionValue("cluster:setup-script", "foo bar zzz");
+        mOptionSetter.setOptionValue("cluster:setup-script", "${FOO} ${BAR} ${ZZZ}");
+        mOptionSetter.setOptionValue("cluster:command-line", "command-line");
+        final CommandResult mockCommandResult = new CommandResult(CommandStatus.SUCCESS);
+        when(mMockRunUtil.runTimedCmd(
+                        Mockito.anyLong(),
+                        Mockito.<OutputStream>any(),
+                        Mockito.<OutputStream>any(),
+                        Mockito.<String[]>any()))
+                .thenReturn(mockCommandResult);
+        Mockito.when(mLauncher.getRunUtil()).thenReturn(mMockRunUtil);
+
+        mLauncher.run(mMockListener);
+
+        Mockito.verify(mMockRunUtil, Mockito.times(2)).setWorkingDir(mRootDir);
+        Mockito.verify(mMockRunUtil).unsetEnvVariable("TF_GLOBAL_CONFIG");
+        Mockito.verify(mMockRunUtil).setEnvVariable("TF_WORK_DIR", mRootDir.getAbsolutePath());
+        Mockito.verify(mMockRunUtil).setEnvVariable("TF_PATH", mTfPath.getAbsolutePath());
+        Mockito.verify(mMockRunUtil).setEnvVariable("BAR", "bar");
+        Mockito.verify(mMockRunUtil).setEnvVariable("FOO", "foo");
+        Mockito.verify(mMockRunUtil).setEnvVariable("ZZZ", "zzz");
+        Mockito.verify(mMockRunUtil, Mockito.times(2))
+                .runTimedCmd(
+                        Mockito.anyLong(),
+                        Mockito.<OutputStream>any(),
+                        Mockito.<OutputStream>any(),
+                        asMatchers(new String[] {"foo", "bar", "zzz"}));
+        Mockito.verify(mMockRunUtil)
+                .runTimedCmd(
+                        Mockito.eq(10000L),
+                        Mockito.<OutputStream>any(),
+                        Mockito.<OutputStream>any(),
+                        asMatchers(
+                                new String[] {
+                                    SystemUtil.getRunningJavaBinaryPath().getAbsolutePath(),
+                                    "-cp",
+                                    classpath,
+                                    "com.android.tradefed.command.CommandRunner",
+                                    "command-line",
+                                    "--serial",
+                                    DEVICE_SERIAL
+                                }));
+    }
+
+    @Test
+    public void testRun_withUseSubprocessReporting()
+            throws DeviceNotAvailableException, ConfigurationException, IOException {
+        mInvocationContext.addAllocatedDevice("foo", mMockTestDevice);
+        final String classpath = String.format("%s/*", mTfPath);
+        mOptionSetter.setOptionValue("cluster:env-var", "TF_PATH", mTfPath.getAbsolutePath());
+        mOptionSetter.setOptionValue("cluster:command-line", "command-line");
+        mOptionSetter.setOptionValue("cluster:use-subprocess-reporting", "true");
+        when(mMockSubprocessTestResultsParser.getSocketServerPort()).thenReturn(123);
+        Mockito.when(
+                        mLauncher.createSubprocessTestResultsParser(
+                                Mockito.any(), Mockito.anyBoolean(), Mockito.any()))
+                .thenReturn(mMockSubprocessTestResultsParser);
+        final CommandResult mockCommandResult = new CommandResult(CommandStatus.SUCCESS);
+        when(mMockRunUtil.runTimedCmd(
+                        Mockito.anyLong(),
+                        Mockito.<OutputStream>any(),
+                        Mockito.<OutputStream>any(),
+                        Mockito.<String[]>any()))
+                .thenReturn(mockCommandResult);
+        final File subprocessReporterConfig = new File(mRootDir, "command-line.xml");
+        Mockito.when(mLauncher.getRunUtil()).thenReturn(mMockRunUtil);
+
+        mLauncher.run(mMockListener);
+
+        String subprocessJar =
+                FileUtil.findFile(mRootDir, "subprocess-results-reporter.jar").getAbsolutePath();
+        assertTrue(subprocessReporterConfig.exists());
+        Mockito.verify(mMockRunUtil, Mockito.times(2)).setWorkingDir(mRootDir);
+        Mockito.verify(mMockRunUtil).unsetEnvVariable("TF_GLOBAL_CONFIG");
+        Mockito.verify(mMockRunUtil).setEnvVariable("TF_WORK_DIR", mRootDir.getAbsolutePath());
+        Mockito.verify(mMockRunUtil).setEnvVariable("TF_PATH", mTfPath.getAbsolutePath());
+        Mockito.verify(mMockRunUtil).unsetEnvVariable("TF_GLOBAL_CONFIG");
+        Mockito.verify(mMockRunUtil)
+                .runTimedCmd(
+                        Mockito.eq(10000L),
+                        Mockito.<OutputStream>any(),
+                        Mockito.<OutputStream>any(),
+                        asMatchers(
+                                new String[] {
+                                    SystemUtil.getRunningJavaBinaryPath().getAbsolutePath(),
+                                    "-cp",
+                                    subprocessJar + ":" + classpath,
+                                    "com.android.tradefed.command.CommandRunner",
+                                    subprocessReporterConfig.getName(),
+                                    "--serial",
+                                    DEVICE_SERIAL,
+                                }));
+    }
+}
diff --git a/tests/src/com/android/tradefed/cluster/ClusterCommandSchedulerTest.java b/tests/src/com/android/tradefed/cluster/ClusterCommandSchedulerTest.java
new file mode 100644
index 0000000..5d9c969
--- /dev/null
+++ b/tests/src/com/android/tradefed/cluster/ClusterCommandSchedulerTest.java
@@ -0,0 +1,1514 @@
+/*
+ * Copyright (C) 2019 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.cluster;
+
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
+
+import com.android.tradefed.build.IBuildInfo;
+import com.android.tradefed.command.CommandScheduler;
+import com.android.tradefed.command.remote.DeviceDescriptor;
+import com.android.tradefed.config.Configuration;
+import com.android.tradefed.config.ConfigurationException;
+import com.android.tradefed.config.ConfigurationFactory;
+import com.android.tradefed.config.GlobalConfiguration;
+import com.android.tradefed.config.IConfiguration;
+import com.android.tradefed.config.IDeviceConfiguration;
+import com.android.tradefed.config.Option;
+import com.android.tradefed.device.DeviceAllocationState;
+import com.android.tradefed.device.DeviceNotAvailableException;
+import com.android.tradefed.device.FreeDeviceState;
+import com.android.tradefed.device.IDeviceManager;
+import com.android.tradefed.device.ITestDevice;
+import com.android.tradefed.device.MockDeviceManager;
+import com.android.tradefed.device.NoDeviceException;
+import com.android.tradefed.device.StubDevice;
+import com.android.tradefed.invoker.IInvocationContext;
+import com.android.tradefed.invoker.InvocationContext;
+import com.android.tradefed.metrics.proto.MetricMeasurement.Metric;
+import com.android.tradefed.result.ConsoleResultReporter;
+import com.android.tradefed.result.FileInputStreamSource;
+import com.android.tradefed.result.LogDataType;
+import com.android.tradefed.result.TestDescription;
+import com.android.tradefed.targetprep.BuildError;
+import com.android.tradefed.targetprep.ITargetPreparer;
+import com.android.tradefed.targetprep.TargetSetupError;
+import com.android.tradefed.testtype.DeviceJUnit4ClassRunner.TestLogData;
+import com.android.tradefed.util.ArrayUtil;
+import com.android.tradefed.util.FileUtil;
+import com.android.tradefed.util.MultiMap;
+import com.android.tradefed.util.RunUtil;
+import com.android.tradefed.util.ZipUtil;
+import com.android.tradefed.util.keystore.IKeyStoreClient;
+import com.android.tradefed.util.keystore.StubKeyStoreClient;
+
+import com.android.tradefed.cluster.ClusterCommand.RequestType;
+import com.android.tradefed.cluster.ClusterCommandScheduler.InvocationEventHandler;
+import com.android.tradefed.util.IRestApiHelper;
+import com.google.api.client.http.GenericUrl;
+import com.google.api.client.http.HttpRequestFactory;
+import com.google.api.client.http.HttpResponse;
+import com.google.api.client.http.LowLevelHttpRequest;
+import com.google.api.client.http.LowLevelHttpResponse;
+import com.google.api.client.testing.http.MockHttpTransport;
+import com.google.api.client.testing.http.MockLowLevelHttpRequest;
+import com.google.api.client.testing.http.MockLowLevelHttpResponse;
+import com.google.common.collect.ImmutableMap;
+
+import org.easymock.Capture;
+import org.easymock.EasyMock;
+import org.easymock.IArgumentMatcher;
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.junit.Test;
+import org.mockito.Mockito;
+
+import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.UUID;
+import java.util.Map;
+import java.util.Set;
+import java.util.Stack;
+import java.util.TreeMap;
+import java.util.concurrent.TimeUnit;
+
+/** Unit tests for {@link ClusterCommandScheduler}. */
+@RunWith(JUnit4.class)
+public class ClusterCommandSchedulerTest {
+
+    private static final String CLUSTER_ID = "free_pool";
+    private static final String REQUEST_ID = "request_id";
+    private static final String COMMAND_ID = "command_id";
+    private static final String ATTEMPT_ID = "attempt_id";
+    private static final String TASK_ID = "task_id";
+    private static final String CMD_LINE = "test";
+    private static final String DEVICE_SERIAL = "serial";
+
+    @Rule public TestLogData mTestLog = new TestLogData();
+
+    private IDeviceManager mMockDeviceManager;
+    private IRestApiHelper mMockApiHelper;
+    private IClusterClient mMockClusterClient;
+    private ClusterOptions mMockClusterOptions;
+    private IClusterEventUploader<ClusterCommandEvent> mMockEventUploader;
+    private ClusterCommandScheduler mScheduler;
+    private IClusterEventUploader mMockHostUploader;
+    // Test variable to store the args of last execCommand called by CommandScheduler.
+    Stack<ArrayList<String>> mExecCmdArgs = new Stack<>();
+
+    String[] getExecCommandArgs() {
+        ArrayList<String> execCmdArgs = mExecCmdArgs.pop();
+        String[] args = new String[execCmdArgs.size()];
+        return execCmdArgs.toArray(args);
+    }
+
+    // Explicitly define this, so we can mock it
+    private static interface ICommandEventUploader
+            extends IClusterEventUploader<ClusterCommandEvent> {};
+
+    @Before
+    public void setUp() throws Exception {
+        mMockHostUploader = EasyMock.createMock(IClusterEventUploader.class);
+        mMockDeviceManager = EasyMock.createMock(IDeviceManager.class);
+        mMockApiHelper = EasyMock.createMock(IRestApiHelper.class);
+        mMockEventUploader = EasyMock.createMock(ICommandEventUploader.class);
+        mMockClusterOptions = new ClusterOptions();
+        mMockClusterOptions.setCheckFlashingPermitsLease(false);
+        mMockClusterOptions.setClusterId(CLUSTER_ID);
+        mMockClusterClient =
+                new ClusterClient() {
+                    @Override
+                    public IClusterEventUploader<ClusterCommandEvent> getCommandEventUploader() {
+                        return mMockEventUploader;
+                    }
+
+                    @Override
+                    public IClusterEventUploader getHostEventUploader() {
+                        return mMockHostUploader;
+                    }
+
+                    @Override
+                    public IClusterOptions getClusterOptions() {
+                        return mMockClusterOptions;
+                    }
+
+                    @Override
+                    IRestApiHelper getApiHelper() {
+                        return mMockApiHelper;
+                    }
+                };
+
+        mScheduler =
+                new ClusterCommandScheduler() {
+
+                    @Override
+                    public IClusterOptions getClusterOptions() {
+                        return mMockClusterOptions;
+                    }
+
+                    @Override
+                    IClusterClient getClusterClient() {
+                        return mMockClusterClient;
+                    }
+
+                    @Override
+                    protected IDeviceManager getDeviceManager() {
+                        return mMockDeviceManager;
+                    }
+
+                    @Override
+                    public void execCommand(IScheduledInvocationListener listener, String[] args)
+                            throws ConfigurationException, NoDeviceException {
+                        ArrayList<String> execCmdArgs = new ArrayList<>();
+                        for (String arg : args) {
+                            execCmdArgs.add(arg);
+                        }
+                        mExecCmdArgs.push(execCmdArgs);
+                    }
+
+                    @Override
+                    protected boolean dryRunCommand(
+                            final InvocationEventHandler handler, String[] args) {
+                        return false;
+                    }
+                };
+    }
+
+    private static class FakeHttpTransport extends MockHttpTransport {
+
+        private byte[] mResponseBytes;
+
+        public FakeHttpTransport(byte[] responseBytes) {
+            mResponseBytes = responseBytes;
+        }
+
+        @Override
+        public LowLevelHttpRequest buildRequest(String method, String url) throws IOException {
+            return new MockLowLevelHttpRequest() {
+                @Override
+                public LowLevelHttpResponse execute() throws IOException {
+                    MockLowLevelHttpResponse response = new MockLowLevelHttpResponse();
+                    response.setContent(new ByteArrayInputStream(mResponseBytes));
+                    return response;
+                }
+            };
+        }
+    }
+
+    private HttpResponse buildHttpResponse(String response) throws IOException {
+        HttpRequestFactory factory =
+                new FakeHttpTransport(response.getBytes()).createRequestFactory();
+        // The method and url aren't used by our fake transport, but they can't be null
+        return factory.buildRequest("GET", new GenericUrl("http://example.com"), null).execute();
+    }
+
+    @After
+    public void tearDown() throws Exception {
+        mExecCmdArgs.clear();
+    }
+
+    private DeviceDescriptor createDevice(
+            String product, String variant, DeviceAllocationState state) {
+        return createDevice(DEVICE_SERIAL, product, variant, state);
+    }
+
+    private DeviceDescriptor createDevice(
+            String serial, String product, String variant, DeviceAllocationState state) {
+        return new DeviceDescriptor(
+                serial, false, state, product, variant, "sdkVersion", "buildId", "batteryLevel");
+    }
+
+    @Test
+    public void testGetAvailableDevices() {
+        final List<DeviceDescriptor> deviceList = new ArrayList<>();
+        deviceList.add(createDevice("product1", "variant1", DeviceAllocationState.Available));
+        deviceList.add(createDevice("product2", "variant2", DeviceAllocationState.Available));
+        deviceList.add(createDevice("product2", "variant2", DeviceAllocationState.Allocated));
+        deviceList.add(createDevice("product3", "variant3", DeviceAllocationState.Available));
+        deviceList.add(createDevice("product3", "variant3", DeviceAllocationState.Allocated));
+        deviceList.add(createDevice("product3", "variant3", DeviceAllocationState.Unavailable));
+        EasyMock.expect(mMockDeviceManager.listAllDevices()).andReturn(deviceList);
+
+        EasyMock.replay(mMockDeviceManager);
+        final MultiMap<String, DeviceDescriptor> deviceMap =
+                mScheduler.getDevices(mMockDeviceManager, false);
+        EasyMock.verify(mMockDeviceManager);
+
+        assertTrue(deviceMap.containsKey("product1:variant1"));
+        assertEquals(1, deviceMap.get("product1:variant1").size());
+        assertTrue(deviceMap.containsKey("product2:variant2"));
+        assertEquals(2, deviceMap.get("product2:variant2").size());
+        assertTrue(deviceMap.containsKey("product3:variant3"));
+        assertEquals(3, deviceMap.get("product3:variant3").size());
+    }
+
+    @Test
+    public void testGetDevices_available() {
+        final List<DeviceDescriptor> deviceList = new ArrayList<>();
+        deviceList.add(createDevice("product1", "variant1", DeviceAllocationState.Available));
+        deviceList.add(createDevice("product2", "variant2", DeviceAllocationState.Available));
+        deviceList.add(createDevice("product2", "variant2", DeviceAllocationState.Allocated));
+        deviceList.add(createDevice("product3", "variant3", DeviceAllocationState.Available));
+        deviceList.add(createDevice("product3", "variant3", DeviceAllocationState.Allocated));
+        deviceList.add(createDevice("product3", "variant3", DeviceAllocationState.Unavailable));
+        EasyMock.expect(mMockDeviceManager.listAllDevices()).andReturn(deviceList);
+
+        EasyMock.replay(mMockDeviceManager);
+        final MultiMap<String, DeviceDescriptor> deviceMap =
+                mScheduler.getDevices(mMockDeviceManager, true);
+        EasyMock.verify(mMockDeviceManager);
+
+        assertTrue(deviceMap.containsKey("product1:variant1"));
+        assertEquals(1, deviceMap.get("product1:variant1").size());
+        assertTrue(deviceMap.containsKey("product2:variant2"));
+        assertEquals(1, deviceMap.get("product2:variant2").size());
+        assertTrue(deviceMap.containsKey("product3:variant3"));
+        assertEquals(1, deviceMap.get("product3:variant3").size());
+    }
+
+    @Test
+    public void testGetDevices_LocalhostIpDevices() {
+        final List<DeviceDescriptor> deviceList = new ArrayList<>();
+        deviceList.add(
+                createDevice(
+                        "127.0.0.1:101", "product1", "variant1", DeviceAllocationState.Available));
+        deviceList.add(
+                createDevice(
+                        "127.0.0.1:102", "product1", "variant1", DeviceAllocationState.Available));
+        deviceList.add(createDevice("product2", "variant2", DeviceAllocationState.Allocated));
+        deviceList.add(createDevice("product3", "variant3", DeviceAllocationState.Available));
+        deviceList.add(createDevice("product3", "variant3", DeviceAllocationState.Unavailable));
+        EasyMock.expect(mMockDeviceManager.listAllDevices()).andReturn(deviceList);
+
+        EasyMock.replay(mMockDeviceManager);
+        final MultiMap<String, DeviceDescriptor> deviceMap =
+                mScheduler.getDevices(mMockDeviceManager, true);
+        EasyMock.verify(mMockDeviceManager);
+
+        assertFalse(deviceMap.containsKey("product1:variant1"));
+        assertFalse(deviceMap.containsKey("product2:variant2"));
+        assertTrue(deviceMap.containsKey("product3:variant3"));
+        assertEquals(1, deviceMap.get("product3:variant3").size());
+    }
+
+    @Test
+    public void testGetDevices_NoAvailableDevices() {
+        final List<DeviceDescriptor> deviceList = new ArrayList<>();
+        deviceList.add(createDevice("product1", "variant1", DeviceAllocationState.Allocated));
+        deviceList.add(createDevice("product2", "variant2", DeviceAllocationState.Unavailable));
+        deviceList.add(createDevice("product3", "variant3", DeviceAllocationState.Ignored));
+        EasyMock.expect(mMockDeviceManager.listAllDevices()).andReturn(deviceList);
+
+        EasyMock.replay(mMockDeviceManager);
+        final MultiMap<String, DeviceDescriptor> deviceMap =
+                mScheduler.getDevices(mMockDeviceManager, true);
+        EasyMock.verify(mMockDeviceManager);
+
+        assertTrue(deviceMap.isEmpty());
+    }
+
+    private JSONObject createCommandTask(
+            String requestId, String commandId, String taskId, String attemptId, String commandLine)
+            throws JSONException {
+
+        JSONObject ret = new JSONObject();
+        ret.put(REQUEST_ID, requestId);
+        ret.put(COMMAND_ID, commandId);
+        ret.put(ATTEMPT_ID, attemptId);
+        ret.put(TASK_ID, taskId);
+        ret.put("command_line", commandLine);
+        return ret;
+    }
+
+    private JSONObject createLeaseResponse(JSONObject... tasks) throws JSONException {
+        JSONObject response = new JSONObject();
+        JSONArray array = new JSONArray();
+        for (JSONObject task : tasks) {
+            array.put(task);
+        }
+        response.put("tasks", array);
+        return response;
+    }
+
+    @Test
+    public void testFetchHostCommands() throws Exception {
+        // Create some devices to fetch tasks for
+        mMockClusterOptions.getDeviceGroup().put("group1", "s1");
+        mMockClusterOptions.getDeviceGroup().put("group1", "s2");
+        DeviceDescriptor d1 =
+                createDevice("s1", "product1", "variant1", DeviceAllocationState.Available);
+        DeviceDescriptor d2 =
+                createDevice("s2", "product2", "variant2", DeviceAllocationState.Available);
+        DeviceDescriptor d3 =
+                createDevice("s3", "product2", "variant2", DeviceAllocationState.Available);
+        String runTarget1 = "product1:variant1";
+        String runTarget2 = "product2:variant2";
+        final MultiMap<String, DeviceDescriptor> deviceMap = new MultiMap<>();
+        deviceMap.put(runTarget1, d1);
+        deviceMap.put(runTarget2, d2);
+        deviceMap.put(runTarget2, d3);
+
+        mMockClusterOptions.getNextClusterIds().add("cluster2");
+        mMockClusterOptions.getNextClusterIds().add("cluster3");
+
+        Capture<JSONObject> capture = new Capture<>();
+        // Create some mock responses for the expected REST API calls
+        JSONObject product1Response =
+                createLeaseResponse(createCommandTask("1", "2", "3", "4", "command line 1"));
+        EasyMock.expect(
+                        mMockApiHelper.execute(
+                                EasyMock.eq("POST"),
+                                EasyMock.aryEq(new String[] {"tasks", "leasehosttasks"}),
+                                EasyMock.eq(
+                                        ImmutableMap.of(
+                                                "cluster",
+                                                CLUSTER_ID,
+                                                "hostname",
+                                                ClusterHostUtil.getHostName(),
+                                                "num_tasks",
+                                                Integer.toString(3))),
+                                EasyMock.capture(capture)))
+                .andReturn(buildHttpResponse(product1Response.toString()));
+        // Actually fetch commands
+        EasyMock.replay(mMockApiHelper, mMockEventUploader);
+        final List<ClusterCommand> commands = mScheduler.fetchHostCommands(deviceMap);
+
+        // Verity the http request body is correct.
+        JSONArray deviceInfos = capture.getValue().getJSONArray("device_infos");
+        JSONArray clusterIds = capture.getValue().getJSONArray("next_cluster_ids");
+        assertEquals("group1", deviceInfos.getJSONObject(0).get("group_name"));
+        assertEquals("s1", deviceInfos.getJSONObject(0).get("device_serial"));
+        assertEquals("group1", deviceInfos.getJSONObject(1).get("group_name"));
+        assertEquals("s2", deviceInfos.getJSONObject(1).get("device_serial"));
+        assertFalse(deviceInfos.getJSONObject(2).has("group_name"));
+        assertEquals("s3", deviceInfos.getJSONObject(2).get("device_serial"));
+        assertEquals("cluster2", clusterIds.getString(0));
+        assertEquals("cluster3", clusterIds.getString(1));
+        // Verify that the commands were fetched
+        EasyMock.verify(mMockApiHelper, mMockEventUploader);
+        // expect 1 command allocated per device type based on availability and fetching algorithm
+        assertEquals("commands size mismatch", 1, commands.size());
+        ClusterCommand command = commands.get(0);
+        assertEquals("1", command.getRequestId());
+        assertEquals("2", command.getCommandId());
+        assertEquals("3", command.getTaskId());
+        assertEquals("4", command.getAttemptId());
+        assertEquals("command line 1", command.getCommandLine());
+    }
+
+    @Test
+    public void testFetchHostCommands_withFlashingPermitCheck() throws Exception {
+        // Create some devices to fetch tasks for
+        DeviceDescriptor d1 =
+                createDevice("s1", "product1", "variant1", DeviceAllocationState.Available);
+        String runTarget1 = "product1:variant1";
+        DeviceDescriptor d2 =
+                createDevice("s2", "product1", "variant1", DeviceAllocationState.Available);
+        final MultiMap<String, DeviceDescriptor> deviceMap = new MultiMap<>();
+        deviceMap.put(runTarget1, d1);
+        deviceMap.put(runTarget1, d2);
+
+        mMockClusterOptions.getNextClusterIds().add("cluster2");
+        mMockClusterOptions.setCheckFlashingPermitsLease(true);
+        Capture<JSONObject> capture = new Capture<>();
+        // Create some mock responses for the expected REST API calls
+        JSONObject product1Response =
+                createLeaseResponse(createCommandTask("1", "2", "3", "4", "command line 1"));
+        EasyMock.expect(
+                        mMockApiHelper.execute(
+                                EasyMock.eq("POST"),
+                                EasyMock.aryEq(new String[] {"tasks", "leasehosttasks"}),
+                                EasyMock.eq(
+                                        ImmutableMap.of(
+                                                "cluster",
+                                                CLUSTER_ID,
+                                                "hostname",
+                                                ClusterHostUtil.getHostName(),
+                                                "num_tasks",
+                                                Integer.toString(1))),
+                                EasyMock.capture(capture)))
+                .andReturn(buildHttpResponse(product1Response.toString()));
+        EasyMock.expect(mMockDeviceManager.getAvailableFlashingPermits()).andReturn(1);
+
+        // Actually fetch commands
+        EasyMock.replay(mMockApiHelper, mMockEventUploader, mMockDeviceManager);
+        final List<ClusterCommand> commands = mScheduler.fetchHostCommands(deviceMap);
+        assertEquals(1, commands.size());
+        ClusterCommand command = commands.get(0);
+        assertEquals("1", command.getRequestId());
+        assertEquals("2", command.getCommandId());
+        assertEquals("3", command.getTaskId());
+        assertEquals("4", command.getAttemptId());
+        assertEquals("command line 1", command.getCommandLine());
+    }
+
+    /** Test when the run target pattern contains repeated patterns. */
+    @Test
+    public void testRepeatedPattern() {
+        String format = "foo-{PRODUCT}-{PRODUCT}:{PRODUCT_VARIANT}";
+        mMockClusterOptions.setRunTargetFormat(format);
+        DeviceDescriptor device =
+                new DeviceDescriptor(
+                        DEVICE_SERIAL,
+                        false,
+                        DeviceAllocationState.Available,
+                        "product",
+                        "productVariant",
+                        "sdkVersion",
+                        "buildId",
+                        "batteryLevel");
+        assertEquals(
+                "foo-product-product:productVariant",
+                ClusterHostUtil.getRunTarget(device, format, null));
+    }
+
+    /** Test default behavior when device serial is not set for command task. */
+    @Test
+    public void testExecCommandsWithNoSerials() {
+        List<ClusterCommand> cmds = new ArrayList<>();
+        ClusterCommand cmd = new ClusterCommand(COMMAND_ID, TASK_ID, CMD_LINE);
+        cmds.add(cmd);
+        mScheduler.execCommands(cmds);
+        assertEquals(CMD_LINE, cmds.get(0).getCommandLine());
+        assertArrayEquals(new String[] {CMD_LINE}, getExecCommandArgs());
+    }
+
+    /** If device serial is specified for a command task append serial to it. */
+    @Test
+    public void testExecCommandWithSerial() {
+        List<ClusterCommand> cmds = new ArrayList<>();
+        ClusterCommand cmd = new ClusterCommand(COMMAND_ID, TASK_ID, CMD_LINE);
+        cmd.setTargetDeviceSerials(ArrayUtil.list("deviceSerial"));
+        cmds.add(cmd);
+        mScheduler.execCommands(cmds);
+        assertEquals(CMD_LINE, cmds.get(0).getCommandLine());
+        assertArrayEquals(
+                new String[] {CMD_LINE, "--serial", "deviceSerial"}, getExecCommandArgs());
+    }
+
+    /** Multiple serials specified for a command task. */
+    @Test
+    public void testExecCommandWithMultipleSerials() {
+        List<ClusterCommand> cmds = new ArrayList<>();
+        ClusterCommand cmd = new ClusterCommand(COMMAND_ID, TASK_ID, CMD_LINE);
+        cmd.setTargetDeviceSerials(
+                ArrayUtil.list("deviceSerial0", "deviceSerial1", "deviceSerial2"));
+        cmds.add(cmd);
+        mScheduler.execCommands(cmds);
+        assertEquals(CMD_LINE, cmds.get(0).getCommandLine());
+        assertArrayEquals(
+                new String[] {
+                    CMD_LINE,
+                    "--serial",
+                    "deviceSerial0",
+                    "--serial",
+                    "deviceSerial1",
+                    "--serial",
+                    "deviceSerial2"
+                },
+                getExecCommandArgs());
+    }
+
+    /** Multiple serials specified for multiple commands. */
+    @Test
+    public void testExecCommandWithMultipleCommandsAndSerials() {
+        List<String> serials = ArrayUtil.list("deviceSerial0", "deviceSerial1", "deviceSerial2");
+        List<ClusterCommand> cmds = new ArrayList<>();
+        ClusterCommand cmd0 = new ClusterCommand("command_id0", "task_id0", CMD_LINE);
+        cmd0.setTargetDeviceSerials(serials);
+        cmds.add(cmd0);
+        ClusterCommand cmd1 = new ClusterCommand("command_id1", "task_id1", "test1");
+        cmd1.setTargetDeviceSerials(serials);
+        cmds.add(cmd1);
+        mScheduler.execCommands(cmds);
+        assertEquals(CMD_LINE, cmds.get(0).getCommandLine());
+        assertEquals("test1", cmds.get(1).getCommandLine());
+        assertArrayEquals(
+                new String[] {
+                    "test1",
+                    "--serial",
+                    "deviceSerial0",
+                    "--serial",
+                    "deviceSerial1",
+                    "--serial",
+                    "deviceSerial2"
+                },
+                getExecCommandArgs());
+        assertArrayEquals(
+                new String[] {
+                    CMD_LINE,
+                    "--serial",
+                    "deviceSerial0",
+                    "--serial",
+                    "deviceSerial1",
+                    "--serial",
+                    "deviceSerial2"
+                },
+                getExecCommandArgs());
+    }
+
+    static ClusterCommandEvent checkClusterCommandEvent(
+            ClusterCommandEvent.Type type, Set<String> deviceSerials) {
+        EasyMock.reportMatcher(
+                new IArgumentMatcher() {
+                    @Override
+                    public boolean matches(Object object) {
+                        if (!(object instanceof ClusterCommandEvent)) {
+                            return false;
+                        }
+
+                        ClusterCommandEvent actual = (ClusterCommandEvent) object;
+                        return (TASK_ID.equals(actual.getCommandTaskId())
+                                && deviceSerials.equals(actual.getDeviceSerials())
+                                && actual.getType() == type);
+                    }
+
+                    @Override
+                    public void appendTo(StringBuffer buffer) {
+                        buffer.append("checkEvent(");
+                        buffer.append(type);
+                        buffer.append(")");
+                    }
+                });
+        return null;
+    }
+
+    static ClusterCommandEvent checkClusterCommandEvent(ClusterCommandEvent.Type type) {
+        Set<String> deviceSerials = new HashSet<String>();
+        deviceSerials.add(DEVICE_SERIAL);
+        return checkClusterCommandEvent(type, deviceSerials);
+    }
+
+    @Test
+    public void testInvocationEventHandler() {
+        ClusterCommand mockCommand = new ClusterCommand(COMMAND_ID, TASK_ID, CMD_LINE);
+        IInvocationContext context = new InvocationContext();
+        ITestDevice mockTestDevice = EasyMock.createMock(ITestDevice.class);
+        EasyMock.expect(mockTestDevice.getSerialNumber()).andReturn(DEVICE_SERIAL);
+        EasyMock.expect(mockTestDevice.getIDevice()).andReturn(new StubDevice(DEVICE_SERIAL));
+        context.addAllocatedDevice("", mockTestDevice);
+        IBuildInfo mockBuildInfo = EasyMock.createMock(IBuildInfo.class);
+        context.addDeviceBuildInfo("", mockBuildInfo);
+        ClusterCommandScheduler.InvocationEventHandler handler =
+                mScheduler.new InvocationEventHandler(mockCommand);
+
+        mMockEventUploader.postEvent(
+                checkClusterCommandEvent(ClusterCommandEvent.Type.InvocationInitiated));
+        mMockEventUploader.flush();
+        mMockEventUploader.postEvent(
+                checkClusterCommandEvent(ClusterCommandEvent.Type.InvocationStarted));
+        mMockEventUploader.flush();
+        mMockEventUploader.postEvent(
+                checkClusterCommandEvent(ClusterCommandEvent.Type.TestRunInProgress));
+        EasyMock.expectLastCall().anyTimes();
+        mMockEventUploader.postEvent(
+                checkClusterCommandEvent(ClusterCommandEvent.Type.InvocationEnded));
+        mMockEventUploader.flush();
+        Capture<ClusterCommandEvent> capture = new Capture<>();
+        mMockEventUploader.postEvent(EasyMock.capture(capture));
+        mMockEventUploader.flush();
+
+        EasyMock.replay(mMockEventUploader, mockBuildInfo, mockTestDevice);
+        handler.invocationInitiated(context);
+        handler.invocationStarted(context);
+        handler.testRunStarted("test run", 1);
+        handler.testStarted(new TestDescription("class", CMD_LINE));
+        handler.testEnded(new TestDescription("class", CMD_LINE), new HashMap<String, Metric>());
+        handler.testRunEnded(10L, new HashMap<String, Metric>());
+        handler.invocationEnded(100L);
+        context.addAllocatedDevice(DEVICE_SERIAL, mockTestDevice);
+        context.addInvocationTimingMetric(IInvocationContext.TimingEvent.FETCH_BUILD, 100L);
+        context.addInvocationTimingMetric(IInvocationContext.TimingEvent.SETUP, 200L);
+        Map<ITestDevice, FreeDeviceState> releaseMap = new HashMap<>();
+        releaseMap.put(mockTestDevice, FreeDeviceState.AVAILABLE);
+        handler.invocationComplete(context, releaseMap);
+        EasyMock.verify(mMockEventUploader, mockBuildInfo, mockTestDevice);
+        ClusterCommandEvent capturedEvent = capture.getValue();
+        assertTrue(capturedEvent.getType().equals(ClusterCommandEvent.Type.InvocationCompleted));
+        // Ensure we have not raised an unexpected error
+        assertNull(capturedEvent.getData().get(ClusterCommandEvent.DATA_KEY_ERROR));
+        assertEquals(
+                "0", capturedEvent.getData().get(ClusterCommandEvent.DATA_KEY_FAILED_TEST_COUNT));
+        assertEquals(
+                "100",
+                capturedEvent.getData().get(ClusterCommandEvent.DATA_KEY_FETCH_BUILD_TIME_MILLIS));
+        assertEquals(
+                "200", capturedEvent.getData().get(ClusterCommandEvent.DATA_KEY_SETUP_TIME_MILLIS));
+    }
+
+    /** Test that the error count is the proper one. */
+    @Test
+    public void testInvocationEventHandler_counting() {
+        ClusterCommand mockCommand = new ClusterCommand(COMMAND_ID, TASK_ID, CMD_LINE);
+        IInvocationContext context = new InvocationContext();
+        ITestDevice mockTestDevice = EasyMock.createMock(ITestDevice.class);
+        EasyMock.expect(mockTestDevice.getSerialNumber()).andReturn(DEVICE_SERIAL);
+        EasyMock.expect(mockTestDevice.getIDevice()).andReturn(new StubDevice(DEVICE_SERIAL));
+        IBuildInfo mockBuildInfo = EasyMock.createMock(IBuildInfo.class);
+        context.addAllocatedDevice("", mockTestDevice);
+        context.addDeviceBuildInfo("", mockBuildInfo);
+        ClusterCommandScheduler.InvocationEventHandler handler =
+                mScheduler.new InvocationEventHandler(mockCommand);
+
+        mMockEventUploader.postEvent(
+                checkClusterCommandEvent(ClusterCommandEvent.Type.InvocationInitiated));
+        mMockEventUploader.flush();
+        mMockEventUploader.postEvent(
+                checkClusterCommandEvent(ClusterCommandEvent.Type.InvocationStarted));
+        mMockEventUploader.flush();
+        mMockEventUploader.postEvent(
+                checkClusterCommandEvent(ClusterCommandEvent.Type.TestRunInProgress));
+        EasyMock.expectLastCall().anyTimes();
+        mMockEventUploader.postEvent(
+                checkClusterCommandEvent(ClusterCommandEvent.Type.InvocationEnded));
+        mMockEventUploader.flush();
+        Capture<ClusterCommandEvent> capture = new Capture<>();
+        mMockEventUploader.postEvent(EasyMock.capture(capture));
+        mMockEventUploader.flush();
+
+        EasyMock.replay(mMockEventUploader, mockBuildInfo, mockTestDevice);
+        handler.invocationInitiated(context);
+        handler.invocationStarted(context);
+        handler.testRunStarted("test run", 1);
+        TestDescription tid = new TestDescription("class", CMD_LINE);
+        handler.testStarted(tid);
+        handler.testFailed(tid, "failed");
+        handler.testEnded(tid, new HashMap<String, Metric>());
+        TestDescription tid2 = new TestDescription("class", "test2");
+        handler.testStarted(tid2);
+        handler.testAssumptionFailure(tid2, "I assume I failed");
+        handler.testEnded(tid2, new HashMap<String, Metric>());
+        handler.testRunEnded(10L, new HashMap<String, Metric>());
+        handler.testRunStarted("failed test run", 1);
+        TestDescription tid3 = new TestDescription("class", "test3");
+        handler.testStarted(tid3);
+        handler.testFailed(tid3, "test terminated without result");
+        handler.testRunFailed("test runner crashed");
+        handler.testRunEnded(10L, new HashMap<String, Metric>());
+        handler.invocationEnded(100L);
+        context.addAllocatedDevice(DEVICE_SERIAL, mockTestDevice);
+        Map<ITestDevice, FreeDeviceState> releaseMap = new HashMap<>();
+        releaseMap.put(mockTestDevice, FreeDeviceState.AVAILABLE);
+        handler.invocationComplete(context, releaseMap);
+        EasyMock.verify(mMockEventUploader, mockBuildInfo, mockTestDevice);
+        ClusterCommandEvent capturedEvent = capture.getValue();
+        assertTrue(capturedEvent.getType().equals(ClusterCommandEvent.Type.InvocationCompleted));
+        // Ensure we have not raised an unexpected error
+        assertNull(capturedEvent.getData().get(ClusterCommandEvent.DATA_KEY_ERROR));
+        // We only count test failure and not assumption failures.
+        assertEquals(
+                "2", capturedEvent.getData().get(ClusterCommandEvent.DATA_KEY_FAILED_TEST_COUNT));
+        assertEquals(
+                "1",
+                capturedEvent.getData().get(ClusterCommandEvent.DATA_KEY_FAILED_TEST_RUN_COUNT));
+    }
+
+    @Test
+    public void testInvocationEventHandler_longTestRun() {
+        ClusterCommand mockCommand = new ClusterCommand(COMMAND_ID, TASK_ID, CMD_LINE);
+        IInvocationContext context = new InvocationContext();
+        ITestDevice mockTestDevice = EasyMock.createMock(ITestDevice.class);
+        EasyMock.expect(mockTestDevice.getSerialNumber()).andReturn(DEVICE_SERIAL);
+        EasyMock.expect(mockTestDevice.getIDevice()).andReturn(new StubDevice(DEVICE_SERIAL));
+        context.addAllocatedDevice("", mockTestDevice);
+        IBuildInfo mockBuildInfo = EasyMock.createMock(IBuildInfo.class);
+        context.addDeviceBuildInfo("", mockBuildInfo);
+        ClusterCommandScheduler.InvocationEventHandler handler =
+                mScheduler.new InvocationEventHandler(mockCommand);
+        mMockEventUploader.postEvent(
+                checkClusterCommandEvent(ClusterCommandEvent.Type.InvocationInitiated));
+        mMockEventUploader.flush();
+        mMockEventUploader.postEvent(
+                checkClusterCommandEvent(ClusterCommandEvent.Type.InvocationStarted));
+        mMockEventUploader.flush();
+        mMockEventUploader.postEvent(
+                checkClusterCommandEvent(ClusterCommandEvent.Type.TestRunInProgress));
+        EasyMock.expectLastCall().anyTimes();
+        mMockEventUploader.postEvent(
+                checkClusterCommandEvent(ClusterCommandEvent.Type.InvocationEnded));
+        mMockEventUploader.flush();
+        mMockEventUploader.postEvent(
+                checkClusterCommandEvent(ClusterCommandEvent.Type.InvocationCompleted));
+        mMockEventUploader.flush();
+
+        EasyMock.replay(mMockEventUploader, mockBuildInfo, mockTestDevice);
+        handler.invocationInitiated(context);
+        handler.invocationStarted(context);
+        handler.testRunStarted("test run", 1);
+        handler.testStarted(new TestDescription("class", CMD_LINE));
+        handler.testEnded(new TestDescription("class", CMD_LINE), new HashMap<String, Metric>());
+        handler.testRunEnded(10L, new HashMap<String, Metric>());
+        handler.invocationEnded(100L);
+        context.addAllocatedDevice(DEVICE_SERIAL, mockTestDevice);
+        Map<ITestDevice, FreeDeviceState> releaseMap = new HashMap<>();
+        releaseMap.put(mockTestDevice, FreeDeviceState.AVAILABLE);
+        handler.invocationComplete(context, releaseMap);
+        EasyMock.verify(mMockEventUploader, mockBuildInfo, mockTestDevice);
+    }
+
+    @Test
+    public void testInvocationEventHandler_multiDevice() {
+        ClusterCommand mockCommand = new ClusterCommand(COMMAND_ID, TASK_ID, CMD_LINE);
+        IInvocationContext context = new InvocationContext();
+
+        ITestDevice mockTestDevice1 = EasyMock.createMock(ITestDevice.class);
+        EasyMock.expect(mockTestDevice1.getSerialNumber()).andReturn(DEVICE_SERIAL);
+        EasyMock.expect(mockTestDevice1.getIDevice()).andReturn(new StubDevice(DEVICE_SERIAL));
+        context.addAllocatedDevice("device1", mockTestDevice1);
+        ITestDevice mockTestDevice2 = EasyMock.createMock(ITestDevice.class);
+        EasyMock.expect(mockTestDevice2.getSerialNumber()).andReturn("s2");
+        EasyMock.expect(mockTestDevice2.getIDevice()).andReturn(new StubDevice("s2"));
+        context.addAllocatedDevice("device2", mockTestDevice2);
+        IBuildInfo mockBuildInfo = EasyMock.createMock(IBuildInfo.class);
+        context.addDeviceBuildInfo("", mockBuildInfo);
+        ClusterCommandScheduler.InvocationEventHandler handler =
+                mScheduler.new InvocationEventHandler(mockCommand);
+
+        Set<String> deviceSerials = new HashSet<>();
+        deviceSerials.add(DEVICE_SERIAL);
+        deviceSerials.add("s2");
+        mMockEventUploader.postEvent(
+                checkClusterCommandEvent(
+                        ClusterCommandEvent.Type.InvocationInitiated, deviceSerials));
+        mMockEventUploader.flush();
+
+        EasyMock.replay(mMockEventUploader, mockBuildInfo, mockTestDevice1, mockTestDevice2);
+        handler.invocationInitiated(context);
+    }
+
+    /**
+     * Test that when dry-run is used we validate the config and no ConfigurationException gets
+     * thrown.
+     */
+    @Test
+    public void testExecCommandsWithDryRun() {
+        mScheduler =
+                new ClusterCommandScheduler() {
+
+                    @Override
+                    public IClusterOptions getClusterOptions() {
+                        return mMockClusterOptions;
+                    }
+
+                    @Override
+                    IClusterClient getClusterClient() {
+                        return mMockClusterClient;
+                    }
+
+                    @Override
+                    public void execCommand(IScheduledInvocationListener listener, String[] args)
+                            throws ConfigurationException, NoDeviceException {
+                        ArrayList<String> execCmdArgs = new ArrayList<>();
+                        for (String arg : args) {
+                            execCmdArgs.add(arg);
+                        }
+                        mExecCmdArgs.push(execCmdArgs);
+                    }
+
+                    @Override
+                    protected IKeyStoreClient getKeyStoreClient() {
+                        return new StubKeyStoreClient();
+                    }
+                };
+        ClusterCommand cmd = new ClusterCommand(COMMAND_ID, TASK_ID, "empty --dry-run");
+        mScheduler.execCommands(Arrays.asList(cmd));
+        assertEquals("empty --dry-run", cmd.getCommandLine());
+        // Nothing gets executed
+        assertTrue(mExecCmdArgs.isEmpty());
+    }
+
+    // Helper class for more functional like tests.
+    private class TestableClusterCommandScheduler extends ClusterCommandScheduler {
+
+        private IDeviceManager manager = new MockDeviceManager(1);
+        // track whether stopInvocation was called
+        private boolean stopInvocationCalled;
+
+        @Override
+        public IClusterOptions getClusterOptions() {
+            return mMockClusterOptions;
+        }
+
+        @Override
+        IClusterClient getClusterClient() {
+            return mMockClusterClient;
+        }
+
+        @Override
+        protected boolean dryRunCommand(final InvocationEventHandler handler, String[] args) {
+            return false;
+        }
+
+        @Override
+        protected IDeviceManager getDeviceManager() {
+            return manager;
+        }
+
+        // Direct getter to avoid making getDeviceManager public.
+        public IDeviceManager getTestManager() {
+            return manager;
+        }
+
+        @Override
+        protected void initLogging() {
+            // ignore
+        }
+
+        @Override
+        protected void cleanUp() {
+            // ignore
+        }
+
+        @Override
+        public boolean stopInvocation(int invocationId) {
+            return (stopInvocationCalled = true);
+        }
+
+        public boolean wasStopInvocationCalled() {
+            return stopInvocationCalled;
+        }
+    }
+
+    /** Test that when a provider returns a null build, we still handle it gracefully. */
+    @Test
+    public void testExecCommands_nullBuild() throws Exception {
+        try {
+            GlobalConfiguration.createGlobalConfiguration(new String[] {});
+        } catch (IllegalStateException e) {
+            // in case Global config is already initialized.
+        }
+        File tmpLogDir = FileUtil.createTempDir("clusterschedulertest");
+        TestableClusterCommandScheduler scheduler = new TestableClusterCommandScheduler();
+        List<ClusterCommand> cmds = new ArrayList<>();
+        // StubBuildProvider of empty.xml can return a null instead of build with --return-null
+        ClusterCommand cmd =
+                new ClusterCommand(
+                        COMMAND_ID,
+                        TASK_ID,
+                        "empty --return-null " + "--log-file-path " + tmpLogDir.getAbsolutePath());
+        cmds.add(cmd);
+        IDeviceManager m = scheduler.getTestManager();
+        scheduler.start();
+        try {
+            // execCommands is going to allocate a device to execute the command.
+            scheduler.execCommands(cmds);
+            assertEquals(0, ((MockDeviceManager) m).getQueueOfAvailableDeviceSize());
+            assertEquals(
+                    "empty --return-null --log-file-path " + tmpLogDir.getAbsolutePath(),
+                    cmds.get(0).getCommandLine());
+            scheduler.shutdownOnEmpty();
+            scheduler.join(2000);
+            // Give it a bit of time as the device re-appearing can be slow.
+            RunUtil.getDefault().sleep(200L);
+            // There is only one device so allocation should succeed if device was released.
+            assertEquals(1, ((MockDeviceManager) m).getQueueOfAvailableDeviceSize());
+            ITestDevice device = m.allocateDevice();
+            assertNotNull(device);
+        } finally {
+            scheduler.shutdown();
+            File zipLog = ZipUtil.createZip(tmpLogDir);
+            try (FileInputStreamSource source = new FileInputStreamSource(zipLog, true)) {
+                mTestLog.addTestLog("testExecCommands_nullBuild", LogDataType.ZIP, source);
+            }
+            FileUtil.recursiveDelete(tmpLogDir);
+        }
+    }
+
+    /** Test that when a provider throws a build error retrieval, we still handle it gracefully. */
+    @Test
+    public void testExecCommands_buildRetrievalError() throws Exception {
+        try {
+            // we need to initialize the GlobalConfiguration when running directly in IDE.
+            GlobalConfiguration.createGlobalConfiguration(new String[] {});
+        } catch (IllegalStateException e) {
+            // in case Global config is already initialized, when running in the infra.
+        }
+        TestableClusterCommandScheduler scheduler = new TestableClusterCommandScheduler();
+        File tmpLogDir = FileUtil.createTempDir("clusterschedulertest");
+        List<ClusterCommand> cmds = new ArrayList<>();
+        // StubBuildProvider of empty.xml can throw a build error if requested via
+        // --throw-build-error
+        ClusterCommand cmd =
+                new ClusterCommand(
+                        COMMAND_ID,
+                        TASK_ID,
+                        "empty --throw-build-error --log-file-path " + tmpLogDir.getAbsolutePath());
+        cmds.add(cmd);
+        IDeviceManager m = scheduler.getTestManager();
+        scheduler.start();
+        try {
+            scheduler.execCommands(cmds);
+            assertEquals(0, ((MockDeviceManager) m).getQueueOfAvailableDeviceSize());
+            scheduler.shutdownOnEmpty();
+            scheduler.join(5000);
+            // Give it a bit of time as the device re-appearing can be slow.
+            RunUtil.getDefault().sleep(200L);
+            // There is only one device so allocation should succeed if device was released.
+            assertEquals(1, ((MockDeviceManager) m).getQueueOfAvailableDeviceSize());
+            ITestDevice device = m.allocateDevice();
+            assertNotNull(device);
+        } finally {
+            scheduler.shutdown();
+            FileUtil.recursiveDelete(tmpLogDir);
+        }
+    }
+
+    private ClusterCommand createMockManagedCommand(int numDevices) {
+        return createMockManagedCommand(numDevices, null, null);
+    }
+
+    private ClusterCommand createMockManagedCommand(
+            int numDevices, Integer shardCount, Integer shardIndex) {
+        ClusterCommand cmd =
+                new ClusterCommand(
+                        REQUEST_ID,
+                        COMMAND_ID,
+                        TASK_ID,
+                        "command",
+                        UUID.randomUUID().toString(),
+                        RequestType.MANAGED,
+                        shardCount,
+                        shardIndex);
+        for (int i = 0; i < numDevices; i++) {
+            cmd.getTargetDeviceSerials().add(String.format("serial%d", i));
+        }
+        return cmd;
+    }
+
+    private TestEnvironment createMockTestEnvironment() {
+        TestEnvironment testEnvironment = new TestEnvironment();
+        testEnvironment.addEnvVar("ENV1", "env1");
+        testEnvironment.addEnvVar("ENV2", "env2");
+        testEnvironment.addSetupScripts("script1");
+        testEnvironment.addSetupScripts("script2");
+        testEnvironment.addJavaProperty("JAVA1", "java1");
+        testEnvironment.addJavaProperty("JAVA2", "java2");
+        testEnvironment.setOutputFileUploadUrl("output_file_upload_url");
+        testEnvironment.addOutputFilePattern("output_file_pattern1");
+        testEnvironment.addOutputFilePattern("output_file_pattern2");
+        return testEnvironment;
+    }
+
+    private List<TestResource> createMockTestResources() {
+        List<TestResource> testResources = new ArrayList<TestResource>();
+        testResources.add(new TestResource("name1", "url2"));
+        testResources.add(new TestResource("name2", "url2"));
+        return testResources;
+    }
+
+    private void verifyConfig(
+            IConfiguration config,
+            ClusterCommand cmd,
+            TestEnvironment testEnvironment,
+            List<TestResource> testResources,
+            File workDir) {
+        List<IDeviceConfiguration> deviceConfigs = config.getDeviceConfig();
+        assertEquals(cmd.getTargetDeviceSerials().size(), deviceConfigs.size());
+        for (int i = 0; i < cmd.getTargetDeviceSerials().size(); i++) {
+            String serial = cmd.getTargetDeviceSerials().get(i);
+            Collection<String> serials =
+                    deviceConfigs.get(i).getDeviceRequirements().getSerials(null);
+            assertTrue(serials.size() == 1 && serials.contains(serial));
+        }
+
+        ClusterBuildProvider buildProvider =
+                (ClusterBuildProvider) deviceConfigs.get(0).getBuildProvider();
+        assertEquals(testResources.size(), buildProvider.getTestResources().size());
+        for (TestResource r : testResources) {
+            assertEquals(r.getUrl(), buildProvider.getTestResources().get(r.getName()));
+        }
+        ClusterCommandLauncher test = (ClusterCommandLauncher) config.getTests().get(0);
+        assertEquals(cmd.getCommandLine(), test.getCommandLine());
+
+        Map<String, String> envVars = new TreeMap<>();
+        envVars.put("TF_WORK_DIR", workDir.getAbsolutePath());
+        envVars.putAll(testEnvironment.getEnvVars());
+        assertEquals(envVars, test.getEnvVars());
+        assertEquals(testEnvironment.getSetupScripts(), test.getSetupScripts());
+        assertEquals(testEnvironment.getJavaProperties(), test.getJavaProperties());
+        assertEquals(testEnvironment.useSubprocessReporting(), test.useSubprocessReporting());
+        ClusterLogSaver logSaver = (ClusterLogSaver) config.getLogSaver();
+        assertEquals(cmd.getAttemptId(), logSaver.getAttemptId());
+        assertEquals(
+                String.format(
+                        "%s/%s/%s/",
+                        testEnvironment.getOutputFileUploadUrl(),
+                        cmd.getCommandId(),
+                        cmd.getAttemptId()),
+                logSaver.getOutputFileUploadUrl());
+        assertEquals(testEnvironment.getOutputFilePatterns(), logSaver.getOutputFilePatterns());
+
+        // Verify all Tradefed config objects.
+        for (TradefedConfigObject.Type type : TradefedConfigObject.Type.values()) {
+            String typeName;
+            switch (type) {
+                case TARGET_PREPARER:
+                    typeName = Configuration.TARGET_PREPARER_TYPE_NAME;
+                    break;
+                case RESULT_REPORTER:
+                    typeName = Configuration.RESULT_REPORTER_TYPE_NAME;
+                    break;
+                default:
+                    continue;
+            }
+            List<Object> configObjs;
+            if (TradefedConfigObject.Type.TARGET_PREPARER.equals(type)) {
+                configObjs =
+                        new ArrayList<>(
+                                config.getDeviceConfig().get(0).getAllObjectOfType(typeName));
+            } else {
+                configObjs = new ArrayList<>(config.getAllConfigurationObjectsOfType(typeName));
+            }
+            for (TradefedConfigObject configDef : testEnvironment.getTradefedConfigObjects()) {
+                if (configDef.getType() != type) {
+                    continue;
+                }
+                // If configObjs is empty, it means we failed find objects for some configDefs.
+                assertFalse(configObjs.isEmpty());
+                while (!configObjs.isEmpty()) {
+                    Object configObj = configObjs.remove(0);
+                    if (!configObj.getClass().getName().equals(configDef.getClassName())) {
+                        continue;
+                    }
+                }
+            }
+        }
+    }
+
+    /** Tests an execution of a managed cluster command. */
+    @Test
+    public void testExecManagedClusterCommand() throws Exception {
+        File workDir = null;
+        try {
+            ClusterCommand cmd = createMockManagedCommand(1);
+            workDir = new File(System.getProperty("java.io.tmpdir"), cmd.getAttemptId());
+            TestEnvironment testEnvironment = createMockTestEnvironment();
+            List<TestResource> testResources = createMockTestResources();
+            TestContext testContext = new TestContext();
+            mMockClusterClient = Mockito.spy(mMockClusterClient);
+            Mockito.doReturn(testEnvironment)
+                    .when(mMockClusterClient)
+                    .getTestEnvironment(REQUEST_ID);
+            Mockito.doReturn(testResources).when(mMockClusterClient).getTestResources(REQUEST_ID);
+            Mockito.doReturn(testContext)
+                    .when(mMockClusterClient)
+                    .getTestContext(REQUEST_ID, COMMAND_ID);
+            InvocationEventHandler invocationEventHandler =
+                    mScheduler.new InvocationEventHandler(cmd);
+
+            mScheduler.execManagedClusterCommand(cmd, invocationEventHandler);
+
+            String[] args = getExecCommandArgs();
+            assertTrue(args.length > 0);
+            IConfiguration config =
+                    ConfigurationFactory.getInstance().createConfigurationFromArgs(args);
+            verifyConfig(config, cmd, testEnvironment, testResources, workDir);
+        } finally {
+            if (workDir != null) {
+                // Clean up work directory
+                FileUtil.recursiveDelete(workDir);
+            }
+        }
+    }
+
+    /** Tests an execution of a managed cluster command for multiple devices. */
+    @Test
+    public void testExecManagedClusterCommand_multiDeviceTest() throws Exception {
+        File workDir = null;
+        try {
+            ClusterCommand cmd = createMockManagedCommand(5);
+            workDir = new File(System.getProperty("java.io.tmpdir"), cmd.getAttemptId());
+            TestEnvironment testEnvironment = createMockTestEnvironment();
+            List<TestResource> testResources = createMockTestResources();
+            TestContext testContext = new TestContext();
+            mMockClusterClient = Mockito.spy(mMockClusterClient);
+            Mockito.doReturn(testEnvironment)
+                    .when(mMockClusterClient)
+                    .getTestEnvironment(REQUEST_ID);
+            Mockito.doReturn(testResources).when(mMockClusterClient).getTestResources(REQUEST_ID);
+            Mockito.doReturn(testContext)
+                    .when(mMockClusterClient)
+                    .getTestContext(REQUEST_ID, COMMAND_ID);
+            InvocationEventHandler invocationEventHandler =
+                    mScheduler.new InvocationEventHandler(cmd);
+
+            mScheduler.execManagedClusterCommand(cmd, invocationEventHandler);
+
+            String[] args = getExecCommandArgs();
+            assertTrue(args.length > 0);
+            IConfiguration config =
+                    ConfigurationFactory.getInstance().createConfigurationFromArgs(args);
+            verifyConfig(config, cmd, testEnvironment, testResources, workDir);
+        } finally {
+            if (workDir != null) {
+                // Clean up work directory
+                FileUtil.recursiveDelete(workDir);
+            }
+        }
+    }
+
+    /** Tests an execution of a sharded managed cluster command. */
+    @Test
+    public void testExecManagedClusterCommand_shardedTest() throws Exception {
+        File workDir = null;
+        try {
+            ClusterCommand cmd = createMockManagedCommand(1, 100, 0);
+            workDir = new File(System.getProperty("java.io.tmpdir"), cmd.getAttemptId());
+            TestEnvironment testEnvironment = createMockTestEnvironment();
+            List<TestResource> testResources = createMockTestResources();
+            TestContext testContext = new TestContext();
+            mMockClusterClient = Mockito.spy(mMockClusterClient);
+            Mockito.doReturn(testEnvironment)
+                    .when(mMockClusterClient)
+                    .getTestEnvironment(REQUEST_ID);
+            Mockito.doReturn(testResources).when(mMockClusterClient).getTestResources(REQUEST_ID);
+            Mockito.doReturn(testContext)
+                    .when(mMockClusterClient)
+                    .getTestContext(REQUEST_ID, COMMAND_ID);
+            InvocationEventHandler invocationEventHandler =
+                    mScheduler.new InvocationEventHandler(cmd);
+
+            mScheduler.execManagedClusterCommand(cmd, invocationEventHandler);
+
+            String[] args = getExecCommandArgs();
+            assertTrue(args.length > 0);
+            IConfiguration config =
+                    ConfigurationFactory.getInstance().createConfigurationFromArgs(args);
+            verifyConfig(config, cmd, testEnvironment, testResources, workDir);
+        } finally {
+            if (workDir != null) {
+                // Clean up work directory
+                FileUtil.recursiveDelete(workDir);
+            }
+        }
+    }
+
+    /** Tests an execution of a managed cluster command for a IO exception case. */
+    @Test
+    public void testExecManagedClusterCommand_ioException() throws Exception {
+        ClusterCommand cmd = createMockManagedCommand(1);
+        File workDir = new File(System.getProperty("java.io.tmpdir"), cmd.getAttemptId());
+        mMockClusterClient = Mockito.spy(mMockClusterClient);
+        Mockito.doThrow(new IOException()).when(mMockClusterClient).getTestEnvironment(REQUEST_ID);
+        InvocationEventHandler invocationEventHandler = mScheduler.new InvocationEventHandler(cmd);
+
+        try {
+            mScheduler.execManagedClusterCommand(cmd, invocationEventHandler);
+            fail("IOException not thrown");
+        } catch (IOException e) {
+        }
+        assertFalse("work directory was not cleaned up", workDir.exists());
+    }
+
+    /** A mock target preparer to test injected TF config objects. */
+    public static class MockTargetPreparer implements ITargetPreparer {
+
+        @Option(name = "int", description = "An int value")
+        private int mInt;
+
+        @Option(name = "string", description = "A string value")
+        private String mString;
+
+        @Option(name = "list", description = "A list of values")
+        private List<String> mList = new ArrayList<>();
+
+        @Option(name = "map", description = "A map of key/value pairs")
+        private Map<String, String> mMap = new TreeMap<>();
+
+        @Override
+        public void setUp(ITestDevice device, IBuildInfo buildInfo)
+                throws TargetSetupError, BuildError, DeviceNotAvailableException {
+            // Do nothing
+        }
+
+        public int getInt() {
+            return mInt;
+        }
+
+        public String getString() {
+            return mString;
+        }
+
+        public List<String> getList() {
+            return mList;
+        }
+
+        public Map<String, String> getMap() {
+            return mMap;
+        }
+    }
+
+    /** Tests an execution of a managed cluster command with addition config objects. */
+    @Test
+    public void testExecManagedClusterCommand_withTradefedConfigObjects() throws Exception {
+        File workDir = null;
+        try {
+            ClusterCommand cmd = createMockManagedCommand(1);
+            workDir = new File(System.getProperty("java.io.tmpdir"), cmd.getAttemptId());
+            TestEnvironment testEnvironment = createMockTestEnvironment();
+            MultiMap<String, String> optionMap = new MultiMap<>();
+            optionMap.put("int", "1000");
+            optionMap.put("string", "foo");
+            optionMap.put("list", "foo");
+            optionMap.put("list", "bar");
+            optionMap.put("list", "zzz");
+            optionMap.put("map", "foo=bar");
+            testEnvironment.addTradefedConfigObject(
+                    new TradefedConfigObject(
+                            TradefedConfigObject.Type.TARGET_PREPARER,
+                            MockTargetPreparer.class.getName(),
+                            optionMap));
+            testEnvironment.addTradefedConfigObject(
+                    new TradefedConfigObject(
+                            TradefedConfigObject.Type.RESULT_REPORTER,
+                            ConsoleResultReporter.class.getName(),
+                            new MultiMap<>()));
+            List<TestResource> testResources = createMockTestResources();
+            TestContext testContext = new TestContext();
+            mMockClusterClient = Mockito.spy(mMockClusterClient);
+            Mockito.doReturn(testEnvironment)
+                    .when(mMockClusterClient)
+                    .getTestEnvironment(REQUEST_ID);
+            Mockito.doReturn(testResources).when(mMockClusterClient).getTestResources(REQUEST_ID);
+            Mockito.doReturn(testContext)
+                    .when(mMockClusterClient)
+                    .getTestContext(REQUEST_ID, COMMAND_ID);
+            InvocationEventHandler invocationEventHandler =
+                    mScheduler.new InvocationEventHandler(cmd);
+
+            mScheduler.execManagedClusterCommand(cmd, invocationEventHandler);
+
+            String[] args = getExecCommandArgs();
+            assertTrue(args.length > 0);
+            IConfiguration config =
+                    ConfigurationFactory.getInstance().createConfigurationFromArgs(args);
+            verifyConfig(config, cmd, testEnvironment, testResources, workDir);
+
+            // Verify option values.
+            Collection<Object> configObjs =
+                    config.getAllConfigurationObjectsOfType(
+                            Configuration.TARGET_PREPARER_TYPE_NAME);
+            MockTargetPreparer configObj =
+                    (MockTargetPreparer)
+                            configObjs
+                                    .stream()
+                                    .filter(
+                                            (obj) -> {
+                                                return obj instanceof MockTargetPreparer;
+                                            })
+                                    .findFirst()
+                                    .get();
+            assertEquals(1000, configObj.getInt());
+            assertEquals("foo", configObj.getString());
+            assertEquals(Arrays.asList("foo", "bar", "zzz"), configObj.getList());
+            assertEquals("bar", configObj.getMap().get("foo"));
+        } finally {
+            // Clean up work directory
+            FileUtil.recursiveDelete(workDir);
+        }
+    }
+
+    @Test
+    public void testShutdown_stopsHeartbeat() {
+        TestableClusterCommandScheduler scheduler = new TestableClusterCommandScheduler();
+        scheduler.start();
+        assertFalse(scheduler.getHeartbeatThreadPool().isTerminated());
+        scheduler.shutdown();
+        assertTrue(scheduler.getHeartbeatThreadPool().isTerminated());
+    }
+
+    @Test
+    public void testShutdownHard_stopsHeartbeat() {
+        TestableClusterCommandScheduler scheduler = new TestableClusterCommandScheduler();
+        scheduler.start();
+        assertFalse(scheduler.getHeartbeatThreadPool().isTerminated());
+        scheduler.shutdownHard();
+        assertTrue(scheduler.getHeartbeatThreadPool().isTerminated());
+    }
+
+    /**
+     * Ensure that we do not thrown an exception from scheduling the heartbeat after calling
+     * shutdown on the thread pool.
+     */
+    @Test
+    public void testShutdownHearbeat() throws Exception {
+        TestableClusterCommandScheduler scheduler = new TestableClusterCommandScheduler();
+        scheduler.getHeartbeatThreadPool().shutdown();
+        scheduler
+                .getHeartbeatThreadPool()
+                .scheduleAtFixedRate(
+                        new Runnable() {
+                            @Override
+                            public void run() {
+                                RunUtil.getDefault().sleep(500);
+                            }
+                        },
+                        0,
+                        100,
+                        TimeUnit.MILLISECONDS);
+        boolean res = scheduler.getHeartbeatThreadPool().awaitTermination(5, TimeUnit.SECONDS);
+        assertTrue("HeartBeat scheduler did not terminate.", res);
+    }
+
+    /** Tests whether the checkCommandStatus option is respected. */
+    @Test
+    public void testCheckCommandState_option() {
+        TestableClusterCommandScheduler scheduler = new TestableClusterCommandScheduler();
+
+        // create new heartbeat
+        ClusterCommand command = new ClusterCommand(COMMAND_ID, TASK_ID, CMD_LINE);
+        InvocationEventHandler handler = scheduler.new InvocationEventHandler(command);
+        Runnable heartbeat = handler.new HeartbeatSender();
+
+        // populate invocation context
+        IInvocationContext context = Mockito.mock(IInvocationContext.class, RETURNS_DEEP_STUBS);
+        Mockito.when(context.getInvocationId()).thenReturn("1");
+        handler.invocationStarted(context);
+
+        // command status is CANCELED
+        mMockClusterClient = Mockito.mock(IClusterClient.class, RETURNS_DEEP_STUBS);
+        Mockito.when(mMockClusterClient.getCommandState(any(), any()))
+                .thenReturn(ClusterCommand.State.CANCELED);
+
+        // not stopped if check is disabled
+        mMockClusterOptions.setCheckCommandState(false);
+        heartbeat.run();
+        assertFalse(scheduler.wasStopInvocationCalled());
+
+        // stopped if check is enabled
+        mMockClusterOptions.setCheckCommandState(true);
+        heartbeat.run();
+        assertTrue(scheduler.wasStopInvocationCalled());
+
+        Mockito.verify(mMockClusterClient, Mockito.times(1)).getCommandState(any(), any());
+    }
+
+    /** Tests whether the heartbeat can determine the invocationId to stop. */
+    @Test
+    public void testCheckCommandState_invocationId() {
+        TestableClusterCommandScheduler scheduler = new TestableClusterCommandScheduler();
+        mMockClusterOptions.setCheckCommandState(true);
+
+        // create new heartbeat
+        ClusterCommand command = new ClusterCommand(COMMAND_ID, TASK_ID, CMD_LINE);
+        InvocationEventHandler handler = scheduler.new InvocationEventHandler(command);
+        Runnable heartbeat = handler.new HeartbeatSender();
+
+        // command status is CANCELED
+        mMockClusterClient = Mockito.mock(IClusterClient.class, RETURNS_DEEP_STUBS);
+        Mockito.when(mMockClusterClient.getCommandState(any(), any()))
+                .thenReturn(ClusterCommand.State.CANCELED);
+
+        // not stopped without invocation context
+        heartbeat.run();
+        assertFalse(scheduler.wasStopInvocationCalled());
+
+        // not stopped if invocation ID missing
+        IInvocationContext context = Mockito.mock(IInvocationContext.class, RETURNS_DEEP_STUBS);
+        handler.invocationStarted(context);
+        heartbeat.run();
+        assertFalse(scheduler.wasStopInvocationCalled());
+
+        // not stopped if invocation ID is non-numeric
+        Mockito.when(context.getInvocationId()).thenReturn("ID");
+        heartbeat.run();
+        assertFalse(scheduler.wasStopInvocationCalled());
+
+        // stopped if invocation ID is numeric
+        Mockito.when(context.getInvocationId()).thenReturn("1");
+        heartbeat.run();
+        assertTrue(scheduler.wasStopInvocationCalled());
+
+        Mockito.verify(mMockClusterClient, Mockito.times(4)).getCommandState(any(), any());
+    }
+
+    /** Tests whether the heartbeat can determine the cluster command state. */
+    @Test
+    public void testCheckCommandState_status() throws IOException {
+        TestableClusterCommandScheduler scheduler = new TestableClusterCommandScheduler();
+        mMockClusterOptions.setCheckCommandState(true);
+
+        // create new heartbeat
+        ClusterCommand command = new ClusterCommand(COMMAND_ID, TASK_ID, CMD_LINE);
+        InvocationEventHandler handler = scheduler.new InvocationEventHandler(command);
+        Runnable heartbeat = handler.new HeartbeatSender();
+
+        // populate invocation context
+        IInvocationContext context = Mockito.mock(IInvocationContext.class, RETURNS_DEEP_STUBS);
+        Mockito.when(context.getInvocationId()).thenReturn("1");
+        handler.invocationStarted(context);
+
+        mMockApiHelper = Mockito.mock(IRestApiHelper.class);
+
+        // not stopped if status is RUNNING
+        Mockito.when(mMockApiHelper.execute(any(), any(), any(), any()))
+                .thenReturn(buildHttpResponse("{\"state\": \"RUNNING\"}"));
+        heartbeat.run();
+        assertFalse(scheduler.wasStopInvocationCalled());
+
+        // not stopped if status is UNKNOWN
+        Mockito.when(mMockApiHelper.execute(any(), any(), any(), any()))
+                .thenReturn(buildHttpResponse("{\"state\": \"INVALID\"}"));
+        heartbeat.run();
+        assertFalse(scheduler.wasStopInvocationCalled());
+
+        // stopped if status is CANCELED
+        Mockito.when(mMockApiHelper.execute(any(), any(), any(), any()))
+                .thenReturn(buildHttpResponse("{\"state\": \"CANCELED\"}"));
+        heartbeat.run();
+        assertTrue(scheduler.wasStopInvocationCalled());
+
+        Mockito.verify(mMockApiHelper, Mockito.times(3)).execute(any(), any(), any(), any());
+    }
+
+    /** Tests upload events with specific host state. */
+    @Test
+    public void testUploadHostEventWithState() {
+        Capture<ClusterHostEvent> capture = new Capture<>();
+        mMockHostUploader.postEvent(EasyMock.capture(capture));
+        mMockHostUploader.flush();
+        EasyMock.replay(mMockHostUploader);
+        // Ignore exceptions here, only test uploading host states.
+        TestableClusterCommandScheduler scheduler = new TestableClusterCommandScheduler();
+        scheduler.start();
+        EasyMock.verify(mMockHostUploader);
+        ClusterHostEvent hostEvent = capture.getValue();
+        assertEquals(CLUSTER_ID, hostEvent.getClusterId());
+        assertEquals(CommandScheduler.HostState.RUNNING, hostEvent.getHostState());
+        scheduler.stop();
+    }
+}
diff --git a/tests/src/com/android/tradefed/cluster/ClusterCommandTest.java b/tests/src/com/android/tradefed/cluster/ClusterCommandTest.java
new file mode 100644
index 0000000..5c198bf
--- /dev/null
+++ b/tests/src/com/android/tradefed/cluster/ClusterCommandTest.java
@@ -0,0 +1,63 @@
+/*
+ * Copyright (C) 2019 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.cluster;
+
+import org.junit.Assert;
+import org.json.JSONObject;
+import org.json.JSONException;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public final class ClusterCommandTest {
+    @Test
+    public void testFromJsonWithAssignedAttemptId() throws JSONException {
+        JSONObject json = new JSONObject();
+        json.put("request_id", "i123");
+        json.put("command_id", "c123");
+        json.put("task_id", "t123");
+        json.put("command_line", "command line");
+        json.put("attempt_id", "a123");
+
+        ClusterCommand command = ClusterCommand.fromJson(json);
+
+        Assert.assertEquals("i123", command.getRequestId());
+        Assert.assertEquals("c123", command.getCommandId());
+        Assert.assertEquals("t123", command.getTaskId());
+        Assert.assertEquals("command line", command.getCommandLine());
+        Assert.assertEquals("a123", command.getAttemptId());
+    }
+
+    @Test
+    public void testFromJsonWithoutAssignedAttemptId() throws JSONException {
+        JSONObject json = new JSONObject();
+        json.put("request_id", "i123");
+        json.put("command_id", "c123");
+        json.put("task_id", "t123");
+        json.put("command_line", "command line");
+
+        ClusterCommand command = ClusterCommand.fromJson(json);
+
+        Assert.assertEquals("i123", command.getRequestId());
+        Assert.assertEquals("c123", command.getCommandId());
+        Assert.assertEquals("t123", command.getTaskId());
+        Assert.assertEquals("command line", command.getCommandLine());
+        String UUIDPattern =
+                "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$";
+        Assert.assertTrue(command.getAttemptId().matches(UUIDPattern));
+    }
+}
diff --git a/tests/src/com/android/tradefed/cluster/ClusterDeviceMonitorTest.java b/tests/src/com/android/tradefed/cluster/ClusterDeviceMonitorTest.java
new file mode 100644
index 0000000..3b11cc1
--- /dev/null
+++ b/tests/src/com/android/tradefed/cluster/ClusterDeviceMonitorTest.java
@@ -0,0 +1,179 @@
+/*
+ * Copyright (C) 2019 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.cluster;
+
+import com.android.tradefed.command.remote.DeviceDescriptor;
+import com.android.tradefed.config.OptionSetter;
+import com.android.tradefed.util.CommandResult;
+import com.android.tradefed.util.CommandStatus;
+import com.android.tradefed.util.IRunUtil;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.easymock.Capture;
+import org.easymock.EasyMock;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Unit tests for {@link ClusterDeviceMonitor}. */
+@RunWith(JUnit4.class)
+public class ClusterDeviceMonitorTest {
+
+    private static final String PRODCERTSTATUS_KEY = "LOAS status";
+    private static final String KRBSTATUS_KEY = "Kerberos status";
+    private static final String PRODCERTSTATUS_CMD = "prodcertstatus";
+    private static final String KRBSTATUS_CMD = "krbstatus";
+    private IRunUtil mRunUtil = null;
+    private ClusterDeviceMonitor mClusterDeviceMonitor = null;
+    private OptionSetter mClusterDeviceMonitorSetter = null;
+    private ClusterDeviceMonitor.EventDispatcher mEventDispatcher = null;
+    private IClusterOptions mClusterOptions = null;
+    private IClusterEventUploader<ClusterHostEvent> mHostEventUploader = null;
+    private OptionSetter mClusterOptionSetter = null;
+
+    @Before
+    public void setUp() throws Exception {
+        mRunUtil = EasyMock.createMock(IRunUtil.class);
+        mClusterOptions = new ClusterOptions();
+        mHostEventUploader = EasyMock.createMock(IClusterEventUploader.class);
+        mClusterDeviceMonitor =
+                new ClusterDeviceMonitor() {
+                    @Override
+                    public IRunUtil getRunUtil() {
+                        return mRunUtil;
+                    }
+
+                    @Override
+                    List<DeviceDescriptor> listDevices() {
+                        return new ArrayList<DeviceDescriptor>();
+                    }
+                };
+        mClusterDeviceMonitorSetter = new OptionSetter(mClusterDeviceMonitor);
+        mEventDispatcher =
+                mClusterDeviceMonitor.new EventDispatcher() {
+                    @Override
+                    public IClusterOptions getClusterOptions() {
+                        return mClusterOptions;
+                    }
+
+                    @Override
+                    public IClusterEventUploader<ClusterHostEvent> getEventUploader() {
+                        return mHostEventUploader;
+                    }
+                };
+        mClusterOptionSetter = new OptionSetter(mClusterOptions);
+        mClusterOptionSetter.setOptionValue("cluster:cluster", "cluster1");
+        mClusterOptionSetter.setOptionValue("cluster:next-cluster", "cluster2");
+        mClusterOptionSetter.setOptionValue("cluster:next-cluster", "cluster3");
+    }
+
+    @Test
+    public void testDispatch() throws Exception {
+        Capture<ClusterHostEvent> capture = new Capture<>();
+        mHostEventUploader.postEvent(EasyMock.capture(capture));
+        mHostEventUploader.flush();
+        EasyMock.replay(mHostEventUploader);
+        mEventDispatcher.dispatch();
+        EasyMock.verify(mHostEventUploader);
+        ClusterHostEvent hostEvent = capture.getValue();
+        Assert.assertEquals("cluster1", hostEvent.getClusterId());
+        Assert.assertEquals(Arrays.asList("cluster2", "cluster3"), hostEvent.getNextClusterIds());
+    }
+
+    void setOptions() throws Exception {
+        mClusterDeviceMonitorSetter.setOptionValue(
+                "host-info-cmd", PRODCERTSTATUS_KEY, PRODCERTSTATUS_CMD);
+        mClusterDeviceMonitorSetter.setOptionValue("host-info-cmd", KRBSTATUS_KEY, KRBSTATUS_CMD);
+    }
+
+    // Test getting additional host information
+    @Test
+    public void testGetAdditionalHostInfo() throws Exception {
+        setOptions();
+        String prodcertstatusOutput = "LOAS cert expires in 13h 5m";
+        CommandResult prodcertstatusMockResult = new CommandResult();
+        prodcertstatusMockResult.setStdout(prodcertstatusOutput);
+        prodcertstatusMockResult.setStatus(CommandStatus.SUCCESS);
+        EasyMock.expect(
+                        mRunUtil.runTimedCmdSilently(
+                                EasyMock.anyInt(), EasyMock.eq(PRODCERTSTATUS_CMD)))
+                .andReturn(prodcertstatusMockResult)
+                .times(1);
+
+        String krbstatusOutput = "android-test ticket expires in 65d 19h";
+        CommandResult krbstatusMockResult = new CommandResult();
+        krbstatusMockResult.setStdout(krbstatusOutput);
+        krbstatusMockResult.setStatus(CommandStatus.SUCCESS);
+        EasyMock.expect(mRunUtil.runTimedCmdSilently(EasyMock.anyInt(), EasyMock.eq(KRBSTATUS_CMD)))
+                .andReturn(krbstatusMockResult)
+                .times(1);
+        EasyMock.replay(mRunUtil);
+
+        Map<String, String> expected = new HashMap<>();
+        expected.put(PRODCERTSTATUS_KEY, prodcertstatusOutput);
+        expected.put(KRBSTATUS_KEY, krbstatusOutput);
+
+        Assert.assertEquals(expected, mClusterDeviceMonitor.getAdditionalHostInfo());
+        EasyMock.verify(mRunUtil);
+    }
+
+    // Test getting additional host information with no commands to run
+    @Test
+    public void testGetAdditionalHostInfo_noCommands() {
+        Map<String, String> expected = new HashMap<>();
+        Assert.assertEquals(expected, mClusterDeviceMonitor.getAdditionalHostInfo());
+    }
+
+    // Test getting additional host information with failures
+    @Test
+    public void testGetAdditionalHostInfo_commandFailed() throws Exception {
+        setOptions();
+        String prodcertstatusOutput = "LOAS cert expires in 13h 5m";
+        CommandResult prodcertstatusMockResult = new CommandResult();
+        prodcertstatusMockResult.setStdout(prodcertstatusOutput);
+        prodcertstatusMockResult.setStatus(CommandStatus.SUCCESS);
+        EasyMock.expect(
+                        mRunUtil.runTimedCmdSilently(
+                                EasyMock.anyInt(), EasyMock.eq(PRODCERTSTATUS_CMD)))
+                .andReturn(prodcertstatusMockResult)
+                .times(1);
+
+        String krbstatusOutput = "android-test ticket expires in 65d 19h";
+        String krbstatusError = "Some terrible failure";
+        CommandResult krbstatusMockResult = new CommandResult();
+        krbstatusMockResult.setStdout(krbstatusOutput);
+        krbstatusMockResult.setStderr(krbstatusError);
+        krbstatusMockResult.setStatus(CommandStatus.FAILED);
+        EasyMock.expect(mRunUtil.runTimedCmdSilently(EasyMock.anyInt(), EasyMock.eq(KRBSTATUS_CMD)))
+                .andReturn(krbstatusMockResult)
+                .times(1);
+        EasyMock.replay(mRunUtil);
+
+        Map<String, String> expected = new HashMap<>();
+        expected.put(PRODCERTSTATUS_KEY, prodcertstatusOutput);
+        expected.put(KRBSTATUS_KEY, krbstatusError);
+
+        Assert.assertEquals(expected, mClusterDeviceMonitor.getAdditionalHostInfo());
+        EasyMock.verify(mRunUtil);
+    }
+}
diff --git a/tests/src/com/android/tradefed/cluster/ClusterEventUploaderFuncTest.java b/tests/src/com/android/tradefed/cluster/ClusterEventUploaderFuncTest.java
new file mode 100644
index 0000000..388b7f5
--- /dev/null
+++ b/tests/src/com/android/tradefed/cluster/ClusterEventUploaderFuncTest.java
@@ -0,0 +1,88 @@
+/*
+ * Copyright (C) 2019 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.cluster;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import com.android.tradefed.cluster.ClusterEventUploaderTest.Event;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
+
+/** Function tests for {@link ClusterEventUploader}. */
+@RunWith(JUnit4.class)
+public class ClusterEventUploaderFuncTest {
+
+    private ClusterEventUploader<Event> mUploader;
+
+    @Test
+    public void testPostCommandEvent_multipleThread() throws Exception {
+        final Event event1 = new Event("event1");
+        final Event event2 = new Event("event2");
+
+        final Lock lock = new ReentrantLock();
+
+        mUploader =
+                new ClusterEventUploader<Event>() {
+                    @Override
+                    protected void doUploadEvents(List<Event> events) throws IOException {
+                        try {
+                            lock.lock();
+                        } finally {
+                            lock.unlock();
+                        }
+                    }
+                };
+
+        Thread thread1 =
+                new Thread(
+                        new Runnable() {
+                            @Override
+                            public void run() {
+                                mUploader.postEvent(event1);
+                                // Thread1 uses flush will be blocked.
+                                mUploader.flush();
+                            }
+                        });
+        Thread thread2 =
+                new Thread(
+                        new Runnable() {
+                            @Override
+                            public void run() {
+                                mUploader.postEvent(event2);
+                                // Thread2 doesn't use flush will not be blocked.
+                            }
+                        });
+        // Thread1 will be blocked. Thread2 will not be blocked.
+        lock.lock();
+        thread1.start();
+        thread2.start();
+        thread2.join(60 * 1000); // timeout in milliseconds
+        assertTrue(thread1.isAlive());
+        assertFalse(thread2.isAlive());
+        // Unblock thread 1.
+        lock.unlock();
+        thread1.join(60 * 1000); // timeout in milliseconds
+        assertFalse(thread1.isAlive());
+    }
+}
diff --git a/tests/src/com/android/tradefed/cluster/ClusterEventUploaderTest.java b/tests/src/com/android/tradefed/cluster/ClusterEventUploaderTest.java
new file mode 100644
index 0000000..e6a2c80
--- /dev/null
+++ b/tests/src/com/android/tradefed/cluster/ClusterEventUploaderTest.java
@@ -0,0 +1,133 @@
+/*
+ * Copyright (C) 2019 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.cluster;
+
+import static org.junit.Assert.assertEquals;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.io.IOException;
+import java.util.LinkedList;
+import java.util.List;
+
+/** Unit tests for {@link ClusterEventUploader}. */
+@RunWith(JUnit4.class)
+public class ClusterEventUploaderTest {
+
+    private IClusterEventUploader<Event> mUploader;
+    private List<Event> mUploadedEvents = null;
+
+    /** Event class used in tests. */
+    static class Event implements IClusterEvent {
+
+        String mName = null;
+        boolean mUploadSuccess = true;
+
+        public Event(String name) {
+            this(name, true);
+        }
+
+        public Event(String name, boolean uploadSuccess) {
+            mName = name;
+            mUploadSuccess = uploadSuccess;
+        }
+
+        @Override
+        public JSONObject toJSON() throws JSONException {
+            return null;
+        }
+    }
+
+    @Before
+    public void setUp() throws Exception {
+        mUploadedEvents = new LinkedList<>();
+        mUploader =
+                new ClusterEventUploader<Event>() {
+                    @Override
+                    protected void doUploadEvents(List<Event> events) throws IOException {
+                        for (Event e : events) {
+                            if (e.mUploadSuccess) {
+                                mUploadedEvents.add(e);
+                            } else {
+                                throw new IOException();
+                            }
+                        }
+                    }
+                };
+    }
+
+    @Test
+    public void testPostCommandEvent_simpleEvent() {
+        // Create an event to post
+        final Event event = new Event("event");
+        mUploader.postEvent(event);
+        mUploader.flush();
+        assertEquals(1, mUploadedEvents.size());
+        assertEquals("event", mUploadedEvents.get(0).mName);
+    }
+
+    /*
+     * If there are no events in the queue, do not post.
+     */
+    @Test
+    public void testPostCommandEvent_NoEvent() {
+        mUploader.flush();
+        assertEquals(0, mUploadedEvents.size());
+    }
+
+    @Test
+    public void testPostCommandEvent_multipleEvents() {
+        final Event event = new Event("event");
+        mUploader.postEvent(event);
+        mUploader.postEvent(event);
+        mUploader.postEvent(event);
+        mUploader.postEvent(event);
+        mUploader.flush();
+        assertEquals(4, mUploadedEvents.size());
+    }
+
+    @Test
+    public void testPostCommandEvent_multipleBatches() {
+        final Event event = new Event("event");
+        mUploader.setMaxBatchSize(2);
+        mUploader.postEvent(event);
+        mUploader.postEvent(event);
+        mUploader.postEvent(event);
+        mUploader.postEvent(event);
+        mUploader.flush();
+        assertEquals(4, mUploadedEvents.size());
+    }
+
+    @Test
+    public void testPostCommandEvent_failed() {
+        final Event event = new Event("event");
+        final Event failedEvent = new Event("failedEvent", false);
+        mUploader.postEvent(event);
+        mUploader.postEvent(failedEvent);
+        mUploader.flush();
+        assertEquals(1, mUploadedEvents.size());
+        assertEquals("event", mUploadedEvents.get(0).mName);
+        failedEvent.mUploadSuccess = true;
+        mUploader.flush();
+        assertEquals(2, mUploadedEvents.size());
+        assertEquals("failedEvent", mUploadedEvents.get(1).mName);
+    }
+}
diff --git a/tests/src/com/android/tradefed/cluster/ClusterHostUtilTest.java b/tests/src/com/android/tradefed/cluster/ClusterHostUtilTest.java
new file mode 100644
index 0000000..e2c10bd
--- /dev/null
+++ b/tests/src/com/android/tradefed/cluster/ClusterHostUtilTest.java
@@ -0,0 +1,290 @@
+/*
+ * Copyright (C) 2019 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.cluster;
+
+import com.android.ddmlib.IDevice;
+import com.android.ddmlib.IDevice.DeviceState;
+import com.android.tradefed.command.remote.DeviceDescriptor;
+import com.android.tradefed.device.DeviceAllocationState;
+import com.android.tradefed.device.DeviceManager;
+import com.android.tradefed.device.DeviceManager.FastbootDevice;
+import java.security.InvalidParameterException;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.easymock.EasyMock;
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Unit tests for {@link ClusterHostUtil}. */
+@RunWith(JUnit4.class)
+public class ClusterHostUtilTest {
+
+    private static final String DEVICE_SERIAL = "serial";
+
+    @Test
+    public void testIsIpPort() {
+        Assert.assertTrue(ClusterHostUtil.isIpPort("127.0.0.1:101"));
+        Assert.assertTrue(ClusterHostUtil.isIpPort("127.0.0.1"));
+        Assert.assertFalse(ClusterHostUtil.isIpPort(DEVICE_SERIAL));
+        Assert.assertFalse(ClusterHostUtil.isIpPort("127.0.0.1:notaport"));
+    }
+
+    // Test a valid TF version
+    @Test
+    public void testToValidTfVersion() {
+        String version = "12345";
+        String actual = ClusterHostUtil.toValidTfVersion(version);
+        Assert.assertEquals(version, actual);
+    }
+
+    // Test an empty TF version
+    @Test
+    public void testToValidTfVersionWithEmptyVersion() {
+        String version = "";
+        String actual = ClusterHostUtil.toValidTfVersion(version);
+        Assert.assertEquals(ClusterHostUtil.DEFAULT_TF_VERSION, actual);
+    }
+
+    // Test a null TF version
+    @Test
+    public void testToValidTfVersionWithNullVersion() {
+        String version = null;
+        String actual = ClusterHostUtil.toValidTfVersion(version);
+        Assert.assertEquals(ClusterHostUtil.DEFAULT_TF_VERSION, actual);
+    }
+
+    // Test an invalid TF version
+    @Test
+    public void testToValidTfVersionWithInvalidVersion() {
+        String version = "1abcd2efg";
+        String actual = ClusterHostUtil.toValidTfVersion(version);
+        Assert.assertEquals(ClusterHostUtil.DEFAULT_TF_VERSION, actual);
+    }
+
+    // Test default run target if nothing is specified.
+    @Test
+    public void testGetDefaultRunTarget() {
+        DeviceDescriptor device =
+                new DeviceDescriptor(
+                        DEVICE_SERIAL,
+                        false,
+                        DeviceAllocationState.Available,
+                        "product",
+                        "productVariant",
+                        "sdkVersion",
+                        "buildId",
+                        "batteryLevel");
+        Assert.assertEquals(
+                "product:productVariant", ClusterHostUtil.getRunTarget(device, null, null));
+    }
+
+    // Test default run target if nothing is specified, and product == product variant.
+    @Test
+    public void testGetDefaultRunTargetWithSameProductAndProductVariant() {
+        DeviceDescriptor device =
+                new DeviceDescriptor(
+                        DEVICE_SERIAL,
+                        false,
+                        DeviceAllocationState.Available,
+                        "product",
+                        "product",
+                        "sdkVersion",
+                        "buildId",
+                        "batteryLevel");
+        Assert.assertEquals("product", ClusterHostUtil.getRunTarget(device, null, null));
+    }
+
+    // If a constant string run target pattern is set, always return said pattern.
+    @Test
+    public void testSimpleConstantRunTargetMatchPattern() {
+        String format = "foo";
+        DeviceDescriptor device =
+                new DeviceDescriptor(
+                        DEVICE_SERIAL,
+                        false,
+                        DeviceAllocationState.Available,
+                        "product",
+                        "productVariant",
+                        "sdkVersion",
+                        "buildId",
+                        "batteryLevel");
+        Assert.assertEquals("foo", ClusterHostUtil.getRunTarget(device, format, null));
+    }
+
+    // Test run target pattern with a device tag map
+    @Test
+    public void testDeviceTagRunTargetMatchPattern_simple() {
+        String format = "{TAG}";
+        DeviceDescriptor device =
+                new DeviceDescriptor(
+                        DEVICE_SERIAL,
+                        false,
+                        DeviceAllocationState.Available,
+                        "product",
+                        "productVariant",
+                        "sdkVersion",
+                        "buildId",
+                        "batteryLevel");
+        Map<String, String> deviceTag = new HashMap<>();
+        deviceTag.put(DEVICE_SERIAL, "foo");
+        Assert.assertEquals("foo", ClusterHostUtil.getRunTarget(device, format, deviceTag));
+    }
+
+    // Test run target pattern with a device tag map, but the device serial is not in map
+    @Test
+    public void testDeviceTagRunTargetMatchPattern_missingSerial() {
+        String format = "foo{TAG}bar";
+        DeviceDescriptor device =
+                new DeviceDescriptor(
+                        DEVICE_SERIAL,
+                        false,
+                        DeviceAllocationState.Available,
+                        "product",
+                        "productVariant",
+                        "sdkVersion",
+                        "buildId",
+                        "batteryLevel");
+        Map<String, String> deviceTag = Collections.emptyMap();
+        Assert.assertEquals("foobar", ClusterHostUtil.getRunTarget(device, format, deviceTag));
+    }
+
+    // Ensure that invalid run target pattern throws an exception.
+    @Test
+    public void testInvalidRunTargetMetachPattern() {
+        String format = "foo-{INVALID PATTERN}";
+        DeviceDescriptor device =
+                new DeviceDescriptor(
+                        DEVICE_SERIAL,
+                        false,
+                        DeviceAllocationState.Available,
+                        "product",
+                        "productVariant",
+                        "sdkVersion",
+                        "buildId",
+                        "batteryLevel");
+        try {
+            ClusterHostUtil.getRunTarget(device, format, null);
+            Assert.fail("Should have thrown an InvalidParameter exception.");
+        } catch (InvalidParameterException e) {
+            // expected.
+        }
+    }
+
+    // Test all supported run target match patterns.
+    @Test
+    public void testSupportedRunTargetMatchPattern() {
+        String format = "foo-{PRODUCT}-{PRODUCT_VARIANT}-{API_LEVEL}-{DEVICE_PROP:bar}";
+        IDevice mockIDevice = EasyMock.createMock(IDevice.class);
+        EasyMock.expect(mockIDevice.getProperty("bar")).andReturn("zzz");
+        DeviceDescriptor device =
+                new DeviceDescriptor(
+                        DEVICE_SERIAL,
+                        false,
+                        DeviceState.ONLINE,
+                        DeviceAllocationState.Available,
+                        "product",
+                        "productVariant",
+                        "sdkVersion",
+                        "buildId",
+                        "batteryLevel",
+                        "",
+                        "",
+                        "",
+                        "",
+                        mockIDevice);
+        EasyMock.replay(mockIDevice);
+        Assert.assertEquals(
+                "foo-product-productVariant-sdkVersion-zzz",
+                ClusterHostUtil.getRunTarget(device, format, null));
+        EasyMock.verify(mockIDevice);
+    }
+
+    // Test all supported run target match patterns with unknown property.
+    @Test
+    public void testSupportedRunTargetMatchPattern_unknownProperty() {
+        String format = "foo-{PRODUCT}-{PRODUCT_VARIANT}-{API_LEVEL}";
+        DeviceDescriptor device =
+                new DeviceDescriptor(
+                        DEVICE_SERIAL,
+                        false,
+                        DeviceAllocationState.Available,
+                        DeviceManager.UNKNOWN_DISPLAY_STRING,
+                        "productVariant",
+                        "sdkVersion",
+                        "buildId",
+                        "batteryLevel");
+        Assert.assertEquals(
+                DeviceManager.UNKNOWN_DISPLAY_STRING,
+                ClusterHostUtil.getRunTarget(device, format, null));
+    }
+
+    /**
+     * Test PRODUCT_OR_DEVICE_CLASS that can return both product type and the stub device class.
+     * This allows an host to report both physical and stub devices.
+     */
+    @Test
+    public void testSupportedRunTargetMatchPattern_productAndStub() {
+        String format = "{PRODUCT_OR_DEVICE_CLASS}";
+        // with non-stub device we use the requested product
+        DeviceDescriptor device =
+                new DeviceDescriptor(
+                        DEVICE_SERIAL,
+                        false,
+                        DeviceAllocationState.Available,
+                        "product",
+                        "productVariant",
+                        "sdkVersion",
+                        "buildId",
+                        "batteryLevel");
+        Assert.assertEquals("product", ClusterHostUtil.getRunTarget(device, format, null));
+        // with a stub device we use the device class
+        device =
+                new DeviceDescriptor(
+                        DEVICE_SERIAL,
+                        true,
+                        DeviceAllocationState.Available,
+                        "product",
+                        "productVariant",
+                        "sdkVersion",
+                        "buildId",
+                        "batteryLevel",
+                        "deviceClass",
+                        "macAddress",
+                        "simState",
+                        "simOperator");
+        Assert.assertEquals("deviceClass", ClusterHostUtil.getRunTarget(device, format, null));
+        // with a fastboot device we use the product
+        device =
+                new DeviceDescriptor(
+                        DEVICE_SERIAL,
+                        true,
+                        DeviceAllocationState.Available,
+                        "product",
+                        "productVariant",
+                        "sdkVersion",
+                        "buildId",
+                        "batteryLevel",
+                        FastbootDevice.class.getSimpleName(),
+                        "macAddress",
+                        "simState",
+                        "simOperator");
+        Assert.assertEquals("product", ClusterHostUtil.getRunTarget(device, format, null));
+    }
+}
diff --git a/tests/src/com/android/tradefed/cluster/ClusterLogSaverTest.java b/tests/src/com/android/tradefed/cluster/ClusterLogSaverTest.java
new file mode 100644
index 0000000..9589d84
--- /dev/null
+++ b/tests/src/com/android/tradefed/cluster/ClusterLogSaverTest.java
@@ -0,0 +1,342 @@
+/*
+ * Copyright (C) 2019 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.cluster;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import com.android.tradefed.config.ConfigurationException;
+import com.android.tradefed.config.OptionSetter;
+import com.android.tradefed.invoker.InvocationContext;
+import com.android.tradefed.util.FileUtil;
+
+import com.android.tradefed.cluster.ClusterLogSaver.FilePickingStrategy;
+
+import org.json.JSONException;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.mockito.Mockito;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.LinkedHashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipInputStream;
+import java.util.zip.ZipOutputStream;
+
+/** Unit tests for {@link ClusterLogSaverTest}. */
+@RunWith(JUnit4.class)
+public class ClusterLogSaverTest {
+
+    private static final String REQUEST_ID = "request_id";
+    private static final String COMMAND_ID = "command_id";
+
+    private File mWorkDir;
+    private TestOutputUploader mMockTestOutputUploader;
+    private IClusterClient mMockClusterClient;
+    private ClusterLogSaver mClusterLogSaver;
+    private OptionSetter mOptionSetter;
+
+    @Before
+    public void setUp() throws Exception {
+        mWorkDir = FileUtil.createTempDir(this.getClass().getName());
+        mMockTestOutputUploader = Mockito.mock(TestOutputUploader.class);
+        mMockClusterClient = Mockito.mock(IClusterClient.class);
+        mClusterLogSaver = Mockito.spy(new ClusterLogSaver());
+        Mockito.doReturn(mMockTestOutputUploader).when(mClusterLogSaver).getTestOutputUploader();
+        Mockito.doReturn(mMockClusterClient).when(mClusterLogSaver).getClusterClient();
+        mOptionSetter = new OptionSetter(mClusterLogSaver);
+        mOptionSetter.setOptionValue("cluster:root-dir", mWorkDir.getAbsolutePath());
+        mOptionSetter.setOptionValue("cluster:request-id", REQUEST_ID);
+        mOptionSetter.setOptionValue("cluster:command-id", COMMAND_ID);
+    }
+
+    @After
+    public void tearDown() {
+        FileUtil.recursiveDelete(mWorkDir);
+    }
+
+    /** Create an empty file in the test's temporary directory. */
+    private File createMockFile(String path, String name) throws IOException {
+        final File dir = new File(mWorkDir, path);
+        dir.mkdirs();
+        final File file = new File(dir, name);
+        file.createNewFile();
+        return file;
+    }
+
+    /** Create an empty zip file in the test's temporary directory. */
+    private File createMockZipFile(String path, String name) throws IOException {
+        File file = createMockFile(path, name);
+        new ZipOutputStream(new FileOutputStream(file)).close();
+        return file;
+    }
+
+    /** Get the names of all entries in a zip file. */
+    private Set<String> getZipEntries(File file) throws IOException {
+        try (ZipInputStream zip = new ZipInputStream(new FileInputStream(file))) {
+            Set<String> names = new LinkedHashSet<>();
+            for (ZipEntry entry = zip.getNextEntry(); entry != null; entry = zip.getNextEntry()) {
+                names.add(entry.getName());
+            }
+            return names;
+        }
+    }
+
+    @Test
+    public void testFindTestContextFile() throws IOException {
+        final String pattern = "android-cts/results/(?<SESSION>[^/]+)/test_result\\.xml";
+        final String sessionId = "1970.01.01_00.05.10";
+        final File file =
+                createMockFile(
+                        String.format("android-cts/results/%s", sessionId), "test_result.xml");
+        Map<String, String> envVars = new TreeMap<>();
+
+        File contextFile =
+                mClusterLogSaver.findTestContextFile(
+                        mWorkDir, pattern, FilePickingStrategy.PICK_LAST, envVars);
+
+        assertNotNull(contextFile);
+        assertEquals(file.getAbsolutePath(), contextFile.getAbsolutePath());
+        assertEquals(1, envVars.size());
+        assertEquals(sessionId, envVars.get("SESSION"));
+    }
+
+    @Test
+    public void testFindTestContextFile_multipleMatches_pickFirst() throws IOException {
+        final String pattern = "android-cts/results/(?<SESSION>[^/]+)/test_result\\.xml";
+        final String[] sessionIds = new String[] {"1970.01.01_00.05.10", "2018.01.01_00.05.10"};
+        final File[] files =
+                new File[] {
+                    createMockFile(
+                            String.format("android-cts/results/%s", sessionIds[0]),
+                            "test_result.xml"),
+                    createMockFile(
+                            String.format("android-cts/results/%s", sessionIds[1]),
+                            "test_result.xml")
+                };
+        Map<String, String> envVars = new TreeMap<>();
+
+        File contextFile =
+                mClusterLogSaver.findTestContextFile(
+                        mWorkDir, pattern, FilePickingStrategy.PICK_FIRST, envVars);
+
+        assertNotNull(contextFile);
+        assertEquals(files[0].getAbsolutePath(), contextFile.getAbsolutePath());
+        assertEquals(1, envVars.size());
+        assertEquals(sessionIds[0], envVars.get("SESSION"));
+    }
+
+    @Test
+    public void testFindTestContextFile_multipleMatches_pickLast() throws IOException {
+        final String pattern = "android-cts/results/(?<SESSION>[^/]+)/test_result\\.xml";
+        final String[] sessionIds = new String[] {"1970.01.01_00.05.10", "2018.01.01_00.05.10"};
+        final File[] files =
+                new File[] {
+                    createMockFile(
+                            String.format("android-cts/results/%s", sessionIds[0]),
+                            "test_result.xml"),
+                    createMockFile(
+                            String.format("android-cts/results/%s", sessionIds[1]),
+                            "test_result.xml")
+                };
+        Map<String, String> envVars = new TreeMap<>();
+
+        File contextFile =
+                mClusterLogSaver.findTestContextFile(
+                        mWorkDir, pattern, FilePickingStrategy.PICK_LAST, envVars);
+
+        assertNotNull(contextFile);
+        assertEquals(files[1].getAbsolutePath(), contextFile.getAbsolutePath());
+        assertEquals(1, envVars.size());
+        assertEquals(sessionIds[1], envVars.get("SESSION"));
+    }
+
+    @Test
+    public void testFindTestContextFile_noMatch() throws IOException {
+        final String pattern = "android-cts/results/(?<SESSION>[^/]+)/test_result\\.xml";
+        final String sessionId = "1970.01.01_00.05.10";
+        createMockFile(String.format("android-cts/results/%s", sessionId), "test_result2.xml");
+        Map<String, String> envVars = new TreeMap<>();
+
+        File contextFile =
+                mClusterLogSaver.findTestContextFile(
+                        mWorkDir, pattern, FilePickingStrategy.PICK_LAST, envVars);
+
+        assertNull(contextFile);
+        assertEquals(0, envVars.size());
+    }
+
+    @Test
+    public void testFindTestContextFile_existingEnvVar() throws IOException {
+        final String pattern = "android-cts/results/(?<SESSION>[^/]+)/test_result\\.xml";
+        final String sessionId = "1970.01.01_00.05.10";
+        final File file =
+                createMockFile(
+                        String.format("android-cts/results/%s", sessionId), "test_result.xml");
+        Map<String, String> envVars = new TreeMap<>();
+        envVars.put("SESSION", "foo");
+
+        File contextFile =
+                mClusterLogSaver.findTestContextFile(
+                        mWorkDir, pattern, FilePickingStrategy.PICK_LAST, envVars);
+
+        assertNotNull(contextFile);
+        assertEquals(file.getAbsolutePath(), contextFile.getAbsolutePath());
+        assertEquals(1, envVars.size());
+        assertEquals(sessionId, envVars.get("SESSION"));
+    }
+
+    @Test
+    public void testInvocationEnded() throws IOException, ConfigurationException, JSONException {
+        final InvocationContext invocationContext = new InvocationContext();
+        final String outputFileUploadUrl = "output_file_upload_url";
+        final String retryCommandLine = "retry_command_line";
+        // create output files (host_log_\d+ will be skipped)
+        File fooTxtOutputFile = createMockFile("logs", "foo.txt");
+        File fooHtmlOutputFile = createMockFile("results", "foo.html");
+        File fooCtxOutputFile = createMockFile("context", "foo.ctx");
+        createMockFile("logs", "host_log_123456.txt");
+        mOptionSetter.setOptionValue("cluster:output-file-upload-url", outputFileUploadUrl);
+        mOptionSetter.setOptionValue("cluster:output-file-pattern", ".*\\.html");
+        mOptionSetter.setOptionValue("cluster:context-file-pattern", ".*\\.ctx");
+        mOptionSetter.setOptionValue("cluster:retry-command-line", retryCommandLine);
+        final String testOutputUrl = "test_output_url";
+        Mockito.doReturn(testOutputUrl)
+                .when(mMockTestOutputUploader)
+                .uploadFile(Mockito.any(), Mockito.any());
+
+        mClusterLogSaver.invocationStarted(invocationContext);
+        mClusterLogSaver.invocationEnded(0);
+
+        // verify that file names are properly stored including any path prefix
+        final File fileNamesFile = new File(mWorkDir, ClusterLogSaver.FILE_NAMES_FILE_NAME);
+        String expectedFileNames =
+                Stream.of("tool-logs/foo.txt", "foo.html", "foo.ctx")
+                        .sorted()
+                        .collect(Collectors.joining("\n"));
+        assertEquals(expectedFileNames, FileUtil.readStringFromFile(fileNamesFile));
+
+        Mockito.verify(mMockTestOutputUploader).setUploadUrl(outputFileUploadUrl);
+        Mockito.verify(mMockTestOutputUploader).uploadFile(fileNamesFile, null);
+        Mockito.verify(mMockTestOutputUploader)
+                .uploadFile(fooTxtOutputFile, ClusterLogSaver.TOOL_LOG_PATH);
+        Mockito.verify(mMockTestOutputUploader).uploadFile(fooHtmlOutputFile, null);
+        Mockito.verify(mMockTestOutputUploader).uploadFile(fooCtxOutputFile, null);
+        TestContext expTextContext = new TestContext();
+        expTextContext.addTestResource(new TestResource("context/foo.ctx", testOutputUrl));
+        expTextContext.setCommandLine(retryCommandLine);
+        Mockito.verify(mMockClusterClient)
+                .updateTestContext(REQUEST_ID, COMMAND_ID, expTextContext);
+    }
+
+    @Test
+    public void testInvocationEnded_uploadError() throws IOException, ConfigurationException {
+        final InvocationContext invocationContext = new InvocationContext();
+        final String outputFileUploadUrl = "output_file_upload_url";
+        createMockFile("", ClusterLogSaver.FILE_NAMES_FILE_NAME);
+        File fooTxtOutputFile = createMockFile("logs", "foo.txt");
+        File barTxtOutputFile = createMockFile("logs", "bar.txt");
+        File fooCtxOutputFile = createMockFile("context", "foo.ctx");
+        mOptionSetter.setOptionValue("cluster:context-file-pattern", ".*\\.ctx");
+        mOptionSetter.setOptionValue("cluster:output-file-upload-url", outputFileUploadUrl);
+        final String testOutputUrl = "test_output_url";
+        Mockito.doReturn(testOutputUrl)
+                .doThrow(RuntimeException.class)
+                .doReturn(testOutputUrl)
+                .when(mMockTestOutputUploader)
+                .uploadFile(Mockito.any(), Mockito.any());
+
+        mClusterLogSaver.invocationStarted(invocationContext);
+        mClusterLogSaver.invocationEnded(0);
+
+        // verify that file names are properly stored including any path prefix
+        final File fileNamesFile = new File(mWorkDir, ClusterLogSaver.FILE_NAMES_FILE_NAME);
+        String expectedFileNames =
+                Stream.of("tool-logs/foo.txt", "tool-logs/bar.txt", "foo.ctx")
+                        .sorted()
+                        .collect(Collectors.joining("\n"));
+        assertEquals(expectedFileNames, FileUtil.readStringFromFile(fileNamesFile));
+
+        Mockito.verify(mMockTestOutputUploader).setUploadUrl(outputFileUploadUrl);
+        Mockito.verify(mMockTestOutputUploader).uploadFile(fileNamesFile, null);
+        Mockito.verify(mMockTestOutputUploader)
+                .uploadFile(fooTxtOutputFile, ClusterLogSaver.TOOL_LOG_PATH);
+        Mockito.verify(mMockTestOutputUploader)
+                .uploadFile(barTxtOutputFile, ClusterLogSaver.TOOL_LOG_PATH);
+        Mockito.verify(mMockTestOutputUploader).uploadFile(fooCtxOutputFile, null);
+    }
+
+    @Test
+    public void testInvocationEnded_duplicateUpload() throws IOException, ConfigurationException {
+        String outputFileUploadUrl = "output_file_upload_url";
+        mOptionSetter.setOptionValue("cluster:output-file-upload-url", outputFileUploadUrl);
+
+        // create two files with same destination, only the second should get picked
+        mOptionSetter.setOptionValue("cluster:output-file-pattern", ".*\\.txt");
+        mOptionSetter.setOptionValue("cluster:file-picking-strategy", "PICK_LAST");
+        File first = createMockFile("first", "foo.txt");
+        File second = createMockFile("second", "foo.txt");
+
+        InvocationContext invocationContext = new InvocationContext();
+        mClusterLogSaver.invocationStarted(invocationContext);
+        mClusterLogSaver.invocationEnded(0);
+
+        // verify that only one file is recorded in the filename file
+        File fileNamesFile = new File(mWorkDir, ClusterLogSaver.FILE_NAMES_FILE_NAME);
+        assertEquals("foo.txt", FileUtil.readStringFromFile(fileNamesFile));
+
+        // verify that only the second file is uploaded (and filename file)
+        Mockito.verify(mMockTestOutputUploader).setUploadUrl(outputFileUploadUrl);
+        Mockito.verify(mMockTestOutputUploader).uploadFile(fileNamesFile, null);
+        Mockito.verify(mMockTestOutputUploader).uploadFile(second, null);
+        Mockito.verifyNoMoreInteractions(mMockTestOutputUploader);
+    }
+
+    @Test
+    public void testAppendFilesToContext() throws IOException {
+        // create context file
+        File contextFile = createMockZipFile("context", "context.zip");
+
+        // create files to append
+        File first = createMockFile("extra", "1.txt");
+        File second = createMockFile("extra", "2.txt");
+
+        // append files using absolute, relative, and unknown paths
+        mClusterLogSaver.appendFilesToContext(
+                contextFile, Arrays.asList(first.getAbsolutePath(), "extra/2.txt", "unknown.txt"));
+
+        // verify that context file contains the additional files
+        Set<String> entries = getZipEntries(contextFile);
+        assertTrue(entries.contains("1.txt"));
+        assertTrue(entries.contains("2.txt"));
+        assertFalse(entries.contains("unknown.txt")); // unknown file ignored
+    }
+}
diff --git a/tests/src/com/android/tradefed/cluster/SubprocessConfigBuilderTest.java b/tests/src/com/android/tradefed/cluster/SubprocessConfigBuilderTest.java
new file mode 100644
index 0000000..e588940
--- /dev/null
+++ b/tests/src/com/android/tradefed/cluster/SubprocessConfigBuilderTest.java
@@ -0,0 +1,91 @@
+/*
+ * Copyright (C) 2019 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.cluster;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+
+import com.android.tradefed.result.LegacySubprocessResultsReporter;
+import com.android.tradefed.util.FileUtil;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+import org.w3c.dom.NodeList;
+
+import java.io.File;
+import java.io.IOException;
+
+import javax.xml.parsers.DocumentBuilder;
+import javax.xml.parsers.DocumentBuilderFactory;
+
+/** Unit tests for {@link SubprocessConfigBuilder} */
+@RunWith(JUnit4.class)
+public class SubprocessConfigBuilderTest {
+
+    private static final String REPORTER_CLASS = LegacySubprocessResultsReporter.class.getName();
+
+    private SubprocessConfigBuilder mConfigBuilder;
+    private File mWorkDir;
+
+    @Before
+    public void setUp() throws IOException {
+        mConfigBuilder = new SubprocessConfigBuilder();
+        mWorkDir = FileUtil.createTempDir("tfjar");
+    }
+
+    @After
+    public void tearDown() {
+        FileUtil.recursiveDelete(mWorkDir);
+    }
+
+    @Test
+    public void testCreateWrapperConfig() throws Exception {
+        String oriConfigName = "testConfig";
+        String reporterPort = "1024";
+        mConfigBuilder
+                .setWorkingDir(mWorkDir)
+                .setOriginalConfig(oriConfigName)
+                .setPort(reporterPort);
+        File config = mConfigBuilder.build();
+        assertNotNull(config);
+        DocumentBuilderFactory dbFactory = DocumentBuilderFactory.newInstance();
+        DocumentBuilder dBuilder = dbFactory.newDocumentBuilder();
+        Document doc = dBuilder.parse(config);
+        verifyWrapperXml(doc, oriConfigName, reporterPort);
+    }
+
+    private void verifyWrapperXml(Document doc, String oriConfigName, String reporterPort) {
+        NodeList inc = doc.getElementsByTagName("include");
+        assertEquals(1, inc.getLength());
+        String incName = ((Element) inc.item(0)).getAttribute("name");
+        assertEquals(oriConfigName, incName);
+        NodeList reporter = doc.getElementsByTagName("result_reporter");
+        assertEquals(1, reporter.getLength());
+        String reporterClass = ((Element) reporter.item(0)).getAttribute("class");
+        assertEquals(REPORTER_CLASS, reporterClass);
+        NodeList option = ((Element) reporter.item(0)).getElementsByTagName("option");
+        assertEquals(1, option.getLength());
+        String optionName = ((Element) option.item(0)).getAttribute("name");
+        assertEquals("subprocess-report-port", optionName);
+        String optionPort = ((Element) option.item(0)).getAttribute("value");
+        assertEquals(reporterPort, optionPort);
+    }
+}
diff --git a/tests/src/com/android/tradefed/cluster/SubprocessReportingHelperTest.java b/tests/src/com/android/tradefed/cluster/SubprocessReportingHelperTest.java
new file mode 100644
index 0000000..f507b83
--- /dev/null
+++ b/tests/src/com/android/tradefed/cluster/SubprocessReportingHelperTest.java
@@ -0,0 +1,84 @@
+/*
+ * Copyright (C) 2019 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.cluster;
+
+import static org.junit.Assert.*;
+
+import com.android.tradefed.result.LegacySubprocessResultsReporter;
+import com.android.tradefed.util.FileUtil;
+import com.android.tradefed.util.ZipUtil2;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.io.File;
+import java.io.IOException;
+
+/** Unit tests for {@link SubprocessReportingHelper} */
+@RunWith(JUnit4.class)
+public class SubprocessReportingHelperTest {
+    private SubprocessReportingHelper mHelper;
+    private SubprocessConfigBuilder mConfigBuilder;
+    private String reporterClassName;
+    private File mWorkDir;
+
+    @Before
+    public void setUp() throws IOException {
+        mHelper = new SubprocessReportingHelper();
+        mConfigBuilder = new SubprocessConfigBuilder();
+        reporterClassName = LegacySubprocessResultsReporter.class.getName();
+        mWorkDir = FileUtil.createTempDir("tfjar");
+    }
+
+    @After
+    public void tearDown() {
+        FileUtil.recursiveDelete(mWorkDir);
+    }
+
+    @Test
+    public void testCreateSubprocessReporterJar() throws IOException {
+        File jar = mHelper.createSubprocessReporterJar(mWorkDir);
+        assertNotNull(jar);
+        File extractedJar = ZipUtil2.extractZipToTemp(jar, "tmp-jar");
+        try {
+            assertNotNull(FileUtil.findFile(extractedJar, "LegacySubprocessResultsReporter.class"));
+            assertNotNull(FileUtil.findFile(extractedJar, "SubprocessTestResultsParser.class"));
+            assertNotNull(FileUtil.findFile(extractedJar, "SubprocessEventHelper.class"));
+            assertNotNull(FileUtil.findFile(extractedJar, "SubprocessResultsReporter.class"));
+        } finally {
+            FileUtil.recursiveDelete(extractedJar);
+        }
+    }
+
+    @Test
+    public void testGetNewCommandLine() throws IOException {
+        String oldCommandLine = "cts arg1 arg2 arg3";
+        String newCommandLine = mHelper.buildNewCommandConfig(oldCommandLine, "1024", mWorkDir);
+        assertNotNull(FileUtil.findFile(mWorkDir, "cts.xml"));
+        assertEquals("cts.xml arg1 arg2 arg3", newCommandLine);
+    }
+
+    @Test
+    public void testGetNewCommandLine_withSlashes() throws IOException {
+        String oldCommandLine = "foo/bar/cts arg1 arg2 arg3";
+        String newCommandLine = mHelper.buildNewCommandConfig(oldCommandLine, "1024", mWorkDir);
+        assertNotNull(FileUtil.findFile(mWorkDir, "foo\\$bar\\$cts.xml"));
+        assertEquals("foo$bar$cts.xml arg1 arg2 arg3", newCommandLine);
+    }
+}
diff --git a/tests/src/com/android/tradefed/cluster/TestOutputUploaderTest.java b/tests/src/com/android/tradefed/cluster/TestOutputUploaderTest.java
new file mode 100644
index 0000000..13cab2b
--- /dev/null
+++ b/tests/src/com/android/tradefed/cluster/TestOutputUploaderTest.java
@@ -0,0 +1,102 @@
+/*
+ * Copyright (C) 2019 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.cluster;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import com.android.tradefed.util.FileUtil;
+import com.android.tradefed.util.IRunUtil;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.mockito.Mockito;
+
+import java.io.File;
+import java.io.IOException;
+import java.net.URL;
+
+/** Unit tests for {@link TestOutputUploader}. */
+@RunWith(JUnit4.class)
+public class TestOutputUploaderTest {
+
+    private IRunUtil mMockRunUtil;
+    private TestOutputUploader mTestOutputUploader;
+    private File mOutputFile;
+
+    @Before
+    public void setUp() throws IOException {
+        mMockRunUtil = Mockito.mock(IRunUtil.class);
+        mTestOutputUploader =
+                new TestOutputUploader() {
+                    @Override
+                    IRunUtil getRunUtil() {
+                        return mMockRunUtil;
+                    }
+                };
+        mOutputFile = File.createTempFile(this.getClass().getName(), ".log");
+    }
+
+    @After
+    public void tearDown() {
+        mOutputFile.delete();
+    }
+
+    @Test
+    public void testUploadFile_fileProtocol_rootPath() throws IOException {
+        File destFolder = null;
+        try {
+            destFolder = FileUtil.createTempDir(this.getClass().getName());
+            final String uploadUrl = "file://" + destFolder.getAbsolutePath();
+            mTestOutputUploader.setUploadUrl(uploadUrl);
+
+            final String outputFileUrl = mTestOutputUploader.uploadFile(mOutputFile, null);
+
+            assertEquals(uploadUrl + "/" + mOutputFile.getName(), outputFileUrl);
+            final File uploadedFile = new File(new URL(outputFileUrl).getPath());
+            assertTrue(uploadedFile.exists());
+            assertTrue(FileUtil.compareFileContents(mOutputFile, uploadedFile));
+        } finally {
+            if (destFolder != null) {
+                FileUtil.recursiveDelete(destFolder);
+            }
+        }
+    }
+
+    @Test
+    public void testUploadFile_fileProtocol_withDestPath() throws IOException {
+        File destRootFolder = null;
+        try {
+            destRootFolder = FileUtil.createTempDir(this.getClass().getName());
+            final String uploadUrl = "file://" + destRootFolder.getAbsolutePath();
+            mTestOutputUploader.setUploadUrl(uploadUrl);
+
+            final String outputFileUrl = mTestOutputUploader.uploadFile(mOutputFile, "sub_dir");
+
+            assertEquals(uploadUrl + "/" + "sub_dir" + "/" + mOutputFile.getName(), outputFileUrl);
+            final File uploadedFile = new File(new URL(outputFileUrl).getPath());
+            assertTrue(uploadedFile.exists());
+            assertTrue(FileUtil.compareFileContents(mOutputFile, uploadedFile));
+        } finally {
+            if (destRootFolder != null) {
+                FileUtil.recursiveDelete(destRootFolder);
+            }
+        }
+    }
+}
diff --git a/tests/src/com/android/tradefed/util/RestApiHelperTest.java b/tests/src/com/android/tradefed/util/RestApiHelperTest.java
new file mode 100644
index 0000000..c96d006
--- /dev/null
+++ b/tests/src/com/android/tradefed/util/RestApiHelperTest.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright (C) 2019 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.util;
+
+import com.google.api.client.http.GenericUrl;
+import com.google.api.client.http.HttpRequestFactory;
+import com.google.api.client.http.javanet.NetHttpTransport;
+
+import junit.framework.TestCase;
+
+import java.util.Collections;
+
+/** Unit tests for {@link RestApiHelper}. */
+public class RestApiHelperTest extends TestCase {
+    private static final String BASE_URI = "https://www.googleapis.com/test/";
+
+    private RestApiHelper mHelper = null;
+
+    @Override
+    public void setUp() throws Exception {
+        super.setUp();
+
+        HttpRequestFactory requestFactory = new NetHttpTransport().createRequestFactory();
+        mHelper = new RestApiHelper(requestFactory, BASE_URI);
+    }
+
+    @Override
+    public void tearDown() throws Exception {
+        super.tearDown();
+    }
+
+    public void testBuildQueryUri() {
+        String[] uriParts = {"add", "new", "fox"};
+        GenericUrl uri = new GenericUrl(BASE_URI + "add/new/fox");
+
+        assertEquals(uri, mHelper.buildQueryUri(uriParts, Collections.<String, Object>emptyMap()));
+    }
+}