Support acloud list command.

Enable the ability to list remote instances created by the user
and disply the info of instances including avd spec and status.

Bug: 118405737
Test: acloud list && acloud list -v && acloud delete
atest acloud_test

Change-Id: I8420f69c8f8c30046eeffe95c5b5d2d184ec4738
diff --git a/create/local_image_local_instance.py b/create/local_image_local_instance.py
index d7c2981..452fb50 100644
--- a/create/local_image_local_instance.py
+++ b/create/local_image_local_instance.py
@@ -67,7 +67,7 @@
         except errors.LaunchCVDFail as launch_error:
             raise launch_error
 
-        result_report = report.Report("local")
+        result_report = report.Report(constants.LOCAL_INS_NAME)
         result_report.SetStatus(report.Status.SUCCESS)
         result_report.AddData(key="devices",
                               value={"adb_port": constants.DEFAULT_ADB_PORT,
diff --git a/internal/constants.py b/internal/constants.py
index 0ef4156..2825b45 100755
--- a/internal/constants.py
+++ b/internal/constants.py
@@ -105,8 +105,6 @@
 ADB_BIN = "adb"
 
 LABEL_CREATE_BY = "created_by"
-LABEL_FLAVOR = "flavor"
-LABEL_TYPE = "avd_type"
 
 # for list and delete cmd
 INS_KEY_NAME = "name"
@@ -117,8 +115,8 @@
 INS_KEY_ADB = "adb"
 INS_KEY_VNC = "vnc"
 INS_KEY_CREATETIME = "creationTimestamp"
-INS_KEY_AVD_TYPE = LABEL_TYPE
-INS_KEY_AVD_FLAVOR = LABEL_FLAVOR
+INS_KEY_AVD_TYPE = "avd_type"
+INS_KEY_AVD_FLAVOR = "flavor"
 INS_KEY_IS_LOCAL = "remote"
 INS_STATUS_RUNNING = "RUNNING"
 LOCAL_INS_NAME = "local-instance"
diff --git a/internal/lib/cvd_compute_client.py b/internal/lib/cvd_compute_client.py
index ea41dd6..bd85abe 100644
--- a/internal/lib/cvd_compute_client.py
+++ b/internal/lib/cvd_compute_client.py
@@ -117,12 +117,20 @@
         # TODO(b/118406018): deprecate resolution config and use hw_proprty for
         # all create cmds.
         if avd_spec:
-            metadata["avd_type"] = avd_spec.avd_type
-            metadata["flavor"] = avd_spec.flavor
+            metadata[constants.INS_KEY_AVD_TYPE] = avd_spec.avd_type
+            metadata[constants.INS_KEY_AVD_FLAVOR] = avd_spec.flavor
             metadata["cvd_01_x_res"] = avd_spec.hw_property[constants.HW_X_RES]
             metadata["cvd_01_y_res"] = avd_spec.hw_property[constants.HW_Y_RES]
             metadata["cvd_01_dpi"] = avd_spec.hw_property[constants.HW_ALIAS_DPI]
-            metadata["cvd_01_blank_data_disk_size"] = avd_spec.hw_property[constants.HW_ALIAS_DISK]
+            metadata["cvd_01_blank_data_disk_size"] = avd_spec.hw_property[
+                constants.HW_ALIAS_DISK]
+            # Use another METADATA_DISPLAY to record resolution which will be
+            # retrieved in acloud list cmd. We try not to use cvd_01_x_res
+            # since cvd_01_xxx metadata is going to deprecated by cuttlefish.
+            metadata[constants.INS_KEY_DISPLAY] = ("%sx%s (%s)" % (
+                avd_spec.hw_property[constants.HW_X_RES],
+                avd_spec.hw_property[constants.HW_Y_RES],
+                avd_spec.hw_property[constants.HW_ALIAS_DPI]))
         else:
             resolution = self._resolution.split("x")
             metadata["cvd_01_dpi"] = resolution[3]
@@ -141,6 +149,10 @@
                 "ssh_public_key_path is not specified in config, "
                 "only project-wide key will be effective.")
 
+        # Add labels for giving the instances ability to be filter for
+        # acloud list/delete cmds.
+        labels = {constants.LABEL_CREATE_BY: getpass.getuser()}
+
         gcompute_client.ComputeClient.CreateInstance(
             self,
             instance=instance,
@@ -150,4 +162,5 @@
             metadata=metadata,
             machine_type=self._machine_type,
             network=self._network,
-            zone=self._zone)
+            zone=self._zone,
+            labels=labels)
diff --git a/internal/lib/cvd_compute_client_test.py b/internal/lib/cvd_compute_client_test.py
index c5afdc3..e782cff 100644
--- a/internal/lib/cvd_compute_client_test.py
+++ b/internal/lib/cvd_compute_client_test.py
@@ -115,7 +115,8 @@
             metadata=expected_metadata,
             machine_type=self.MACHINE_TYPE,
             network=self.NETWORK,
-            zone=self.ZONE)
+            zone=self.ZONE,
+            labels={constants.LABEL_CREATE_BY: "fake_user"})
 
         #test use local image in the remote instance.
         args = mock.MagicMock()
@@ -132,11 +133,16 @@
         expected_metadata["cvd_01_launch"] = "0"
         expected_metadata["avd_type"] = "cf"
         expected_metadata["flavor"] = "phone"
+        expected_metadata[constants.INS_KEY_DISPLAY] = ("%sx%s (%s)" % (
+            fake_avd_spec.hw_property[constants.HW_X_RES],
+            fake_avd_spec.hw_property[constants.HW_Y_RES],
+            fake_avd_spec.hw_property[constants.HW_ALIAS_DPI]))
         self.cvd_compute_client.CreateInstance(
             self.INSTANCE, self.IMAGE, self.IMAGE_PROJECT, self.TARGET, self.BRANCH,
             self.BUILD_ID, self.KERNEL_BRANCH, self.KERNEL_BUILD_ID,
             self.EXTRA_DATA_DISK_SIZE_GB, fake_avd_spec)
 
+        expected_labels = {constants.LABEL_CREATE_BY: "fake_user"}
         mock_create.assert_called_with(
             self.cvd_compute_client,
             instance=self.INSTANCE,
@@ -146,6 +152,9 @@
             metadata=expected_metadata,
             machine_type=self.MACHINE_TYPE,
             network=self.NETWORK,
-            zone=self.ZONE)
+            zone=self.ZONE,
+            labels=expected_labels)
+
+
 if __name__ == "__main__":
     unittest.main()
diff --git a/internal/lib/gcompute_client.py b/internal/lib/gcompute_client.py
index cd30763..ada5306 100755
--- a/internal/lib/gcompute_client.py
+++ b/internal/lib/gcompute_client.py
@@ -1077,7 +1077,8 @@
                        disk_args=None,
                        image_project=None,
                        gpu=None,
-                       extra_disk_name=None):
+                       extra_disk_name=None,
+                       labels=None):
         """Create a gce instance with a gce image.
 
         Args:
@@ -1097,6 +1098,7 @@
                  None no gpus will be attached. For more details see:
                  https://cloud.google.com/compute/docs/gpus/add-gpus
             extra_disk_name: String,the name of the extra disk to attach.
+            labels: Dict, will be added to the instance's labels.
         """
         disk_args = (disk_args
                      or self._GetDiskArgs(instance, image_name, image_project))
@@ -1113,6 +1115,8 @@
             }],
         }
 
+        if labels is not None:
+            body["labels"] = labels
         if gpu:
             body["guestAccelerators"] = [{
                 "acceleratorType": self.GetAcceleratorUrl(gpu, zone),
diff --git a/list/instance.py b/list/instance.py
index 3e21c2d..87e4efd 100644
--- a/list/instance.py
+++ b/list/instance.py
@@ -65,22 +65,27 @@
         self._ip = None
         self._adb_port = None  # adb port which is forwarding to remote
         self._vnc_port = None  # vnc port which is forwarding to remote
-        self._is_connected = None  # True if ssh tunnel is still connected
+        self._is_ssh_tunnel_connected = None  # True if ssh tunnel is still connected
         self._createtime = None
         self._avd_type = None
         self._avd_flavor = None
         self._is_local = None  # True if this is a local instance
 
     def __repr__(self):
+        """Return full name property for print."""
+        return self._fullname
+
+    def Summary(self):
         """Let's make it easy to see what this class is holding."""
         indent = " " * 3
         representation = []
         representation.append(" name: %s" % self._name)
         representation.append("%s IP: %s" % (indent, self._ip))
-        representation.append("%s create time: %s" %
-                              (indent, self._createtime))
-        representation.append("%s status: %s" %
-                              (indent, self._status))
+        representation.append("%s create time: %s" % (indent, self._createtime))
+        representation.append("%s status: %s" % (indent, self._status))
+        representation.append("%s avd type: %s" % (indent, self._avd_type))
+        representation.append("%s display: %s" % (indent, self._display))
+        representation.append("%s vnc: 127.0.0.1:%s" % (indent, self._vnc_port))
 
         if self._adb_port:
             representation.append("%s adb serial: 127.0.0.1:%s" %
@@ -88,11 +93,6 @@
         else:
             representation.append("%s adb serial: disconnected" % indent)
 
-            representation.append("%s avd type: %s" %
-                                  (indent, self._avd_type))
-            representation.append("%s display: %s" %
-                                  (indent, self._display))
-
         return "\n".join(representation)
 
     @property
@@ -131,9 +131,9 @@
         return self._vnc_port
 
     @property
-    def is_connected(self):
+    def is_ssh_tunnel_connected(self):
         """Return the connect status."""
-        return self._is_connected
+        return self._is_ssh_tunnel_connected
 
     @property
     def createtime(self):
@@ -190,6 +190,7 @@
                 local_instance._vnc_port = constants.DEFAULT_VNC_PORT
                 local_instance._display = ("%sx%s (%s)" % (x_res, y_res, dpi))
                 local_instance._is_local = True
+                local_instance._is_ssh_tunnel_connected = True
                 return local_instance
         return None
 
@@ -233,34 +234,34 @@
 
         # Find ssl tunnel info.
         if ip:
-            forwarded_ports = self._GetAdbVncPortFromSSHTunnel(ip)
+            forwarded_ports = self.GetAdbVncPortFromSSHTunnel(ip)
             self._ip = ip
             self._adb_port = forwarded_ports.adb_port
             self._vnc_port = forwarded_ports.vnc_port
             if self._adb_port:
-                self._is_connected = True
+                self._is_ssh_tunnel_connected = True
                 self._fullname = (_FULL_NAME_STRING %
                                   {"device_serial": "127.0.0.1:%d" % self._adb_port,
                                    "instance_name": self._name})
             else:
-                self._is_connected = False
+                self._is_ssh_tunnel_connected = False
                 self._fullname = (_FULL_NAME_STRING %
                                   {"device_serial": "not connected",
                                    "instance_name": self._name})
 
-        # Find avd type from labels
-        labels = gce_instance.get("labels")
-        self._avd_type = labels[constants.LABEL_TYPE]
-        self._avd_flavor = labels[constants.LABEL_FLAVOR]
-
-        # Get display information.
+        # Get metadata
         for metadata in gce_instance.get("metadata", {}).get("items", []):
-            if metadata["key"] == constants.INS_KEY_DISPLAY:
-                self._display = metadata["value"]
-                break
+            key = metadata["key"]
+            value = metadata["value"]
+            if key == constants.INS_KEY_DISPLAY:
+                self._display = value
+            elif key == constants.INS_KEY_AVD_TYPE:
+                self._avd_type = value
+            elif key == constants.INS_KEY_AVD_FLAVOR:
+                self._avd_flavor = value
 
     @staticmethod
-    def _GetAdbVncPortFromSSHTunnel(ip):
+    def GetAdbVncPortFromSSHTunnel(ip):
         """Get forwarding adb and vnc port from ssh tunnel.
 
         Args:
diff --git a/list/instance_test.py b/list/instance_test.py
index 4fed8fa..6010d5c 100644
--- a/list/instance_test.py
+++ b/list/instance_test.py
@@ -60,7 +60,7 @@
     def testGetAdbVncPortFromSSHTunnel(self):
         """"Test Get forwarding adb and vnc port from ssh tunnel."""
         self.Patch(subprocess, "check_output", return_value=self.PS_SSH_TUNNEL)
-        forwarded_ports = instance.RemoteInstance._GetAdbVncPortFromSSHTunnel("fake_ip")
+        forwarded_ports = instance.RemoteInstance.GetAdbVncPortFromSSHTunnel("fake_ip")
         self.assertEqual(54321, forwarded_ports.adb_port)
         self.assertEqual(12345, forwarded_ports.vnc_port)
 
@@ -84,25 +84,25 @@
                                                   constants.ADB_PORT])
         self.Patch(
             instance.RemoteInstance,
-            "_GetAdbVncPortFromSSHTunnel",
+            "GetAdbVncPortFromSSHTunnel",
             return_value=forwarded_ports(vnc_port=fake_vnc, adb_port=fake_adb))
 
-        # test is_connected will be true if ssh tunnel connection is found
+        # test is_ssh_tunnel_connected will be true if ssh tunnel connection is found
         instance_info = instance.RemoteInstance(gce_instance)
-        self.assertTrue(instance_info.is_connected)
+        self.assertTrue(instance_info.is_ssh_tunnel_connected)
         self.assertEqual(instance_info.forwarding_adb_port, fake_adb)
         self.assertEqual(instance_info.forwarding_vnc_port, fake_vnc)
         expected_full_name = "device serial: 127.0.0.1:%s (%s)" % (fake_adb,
                                                                    instance_info.name)
         self.assertEqual(expected_full_name, instance_info.fullname)
 
-        # test is_connected will be false if ssh tunnel connection is not found
+        # test is_ssh_tunnel_connected will be false if ssh tunnel connection is not found
         self.Patch(
             instance.RemoteInstance,
-            "_GetAdbVncPortFromSSHTunnel",
+            "GetAdbVncPortFromSSHTunnel",
             return_value=forwarded_ports(vnc_port=None, adb_port=None))
         instance_info = instance.RemoteInstance(gce_instance)
-        self.assertFalse(instance_info.is_connected)
+        self.assertFalse(instance_info.is_ssh_tunnel_connected)
         expected_full_name = ("device serial: not connected (%s)" %
                               instance_info.name)
         self.assertEqual(expected_full_name, instance_info.fullname)
diff --git a/list/list.py b/list/list.py
new file mode 100644
index 0000000..ec2e504
--- /dev/null
+++ b/list/list.py
@@ -0,0 +1,136 @@
+# Copyright 2018 - The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+r"""List entry point.
+
+List will handle all the logic related to list a local/remote instance
+of an Android Virtual Device.
+"""
+
+from __future__ import print_function
+import getpass
+import logging
+
+from acloud.internal import constants
+from acloud.internal.lib import auth
+from acloud.internal.lib import gcompute_client
+from acloud.internal.lib import utils
+from acloud.list import instance
+from acloud.public import config
+
+logger = logging.getLogger(__name__)
+
+
+def _ProcessInstances(instance_list):
+    """Get more details of remote instances.
+
+    Args:
+        instance_list: List of dicts which contain info about the remote instances,
+                       they're the response from the GCP GCE api.
+
+    Returns:
+        instance_detail_list: List of instance.Instance() with detail info.
+    """
+    return [instance.RemoteInstance(gce_instance) for gce_instance in instance_list]
+
+
+def PrintInstancesDetails(instance_list, verbose=False):
+    """Display instances information.
+
+    Example of non-verbose case:
+    [1]device serial: 127.0.0.1:55685 (ins-1ff036dc-5128057-cf-x86-phone-userdebug)
+    [2]device serial: 127.0.0.1:60979 (ins-80952669-5128057-cf-x86-phone-userdebug)
+    [3]device serial: 127.0.0.1:6520 (local-instance)
+
+    Example of verbose case:
+    [1] name: ins-244710f0-5091715-aosp-cf-x86-phone-userdebug
+        IP: None
+        create time: 2018-10-25T06:32:08.182-07:00
+        status: TERMINATED
+        avd type: cuttlefish
+        display: 1080x1920 (240)
+
+    [2] name: ins-82979192-5091715-aosp-cf-x86-phone-userdebug
+        IP: 35.232.77.15
+        adb serial: 127.0.0.1:33537
+        create time: 2018-10-25T06:34:22.716-07:00
+        status: RUNNING
+        avd type: cuttlefish
+        display: 1080x1920 (240)
+
+    Args:
+        verbose: Boolean, True to print all details and only full name if False.
+        instance_list: List of instances.
+    """
+    if not instance_list:
+        print("No remote or local instances found")
+
+    for num, instance_info in enumerate(instance_list, 1):
+        idx_str = "[%d]" % num
+        utils.PrintColorString(idx_str, end="")
+        if verbose:
+            print(instance_info.Summary())
+            # add space between instances in verbose mode.
+            print("")
+        else:
+            print(instance_info)
+
+
+def GetRemoteInstances(cfg):
+    """Look for remote instances.
+
+    We're going to query the GCP project for all instances that created by user.
+
+    Args:
+        cfg: AcloudConfig object.
+
+    Returns:
+        instance_list: List of remote instances.
+    """
+    credentials = auth.CreateCredentials(cfg)
+    compute_client = gcompute_client.ComputeClient(cfg, credentials)
+    filter_item = "labels.%s=%s" % (constants.LABEL_CREATE_BY, getpass.getuser())
+    all_instances = compute_client.ListInstances(cfg.zone,
+                                                 instance_filter=filter_item)
+    logger.debug("Instance list from: %s (filter: %s\n%s):",
+                 cfg.zone, filter_item, all_instances)
+
+    return _ProcessInstances(all_instances)
+
+
+def GetInstances(cfg):
+    """Look for remote/local instances.
+
+    Args:
+        cfg: AcloudConfig object.
+
+    Returns:
+        instance_list: List of instances.
+    """
+    instances_list = GetRemoteInstances(cfg)
+    local_instance = instance.LocalInstance()
+    if local_instance:
+        instances_list.append(local_instance)
+
+    return instances_list
+
+
+def Run(args):
+    """Run list.
+
+    Args:
+        args: Namespace object from argparse.parse_args.
+    """
+    cfg = config.GetAcloudConfig(args)
+    instance_list = GetInstances(cfg)
+    PrintInstancesDetails(instance_list, args.verbose)
diff --git a/list/list_args.py b/list/list_args.py
new file mode 100644
index 0000000..d9d7170
--- /dev/null
+++ b/list/list_args.py
@@ -0,0 +1,35 @@
+# Copyright 2018 - The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+r"""List args.
+
+Defines the list arg parser that holds list specific args.
+"""
+
+CMD_LIST = "list"
+
+
+def GetListArgParser(subparser):
+    """Return the list arg parser.
+
+    Args:
+       subparser: argparse.ArgumentParser that is attached to main acloud cmd.
+
+    Returns:
+        argparse.ArgumentParser with list options defined.
+    """
+    list_parser = subparser.add_parser(CMD_LIST)
+    list_parser.required = False
+    list_parser.set_defaults(which=CMD_LIST)
+
+    return list_parser
diff --git a/public/acloud_main.py b/public/acloud_main.py
index 2e31da5..bc392e7 100644
--- a/public/acloud_main.py
+++ b/public/acloud_main.py
@@ -62,6 +62,8 @@
 from acloud.delete import delete
 from acloud.delete import delete_args
 from acloud.internal import constants
+from acloud.list import list as list_instances
+from acloud.list import list_args
 from acloud.metrics import metrics
 from acloud.public import acloud_common
 from acloud.public import config
@@ -78,7 +80,6 @@
 # Commands
 CMD_CREATE_CUTTLEFISH = "create_cf"
 CMD_CREATE_GOLDFISH = "create_gf"
-CMD_DELETE = "delete"
 CMD_CLEANUP = "cleanup"
 CMD_SSHKEY = "project_sshkey"
 
@@ -98,6 +99,7 @@
         create_args.CMD_CREATE,
         CMD_CREATE_CUTTLEFISH,
         CMD_CREATE_GOLDFISH,
+        list_args.CMD_LIST,
         delete_args.CMD_DELETE,
     ])
     parser = argparse.ArgumentParser(
@@ -228,9 +230,12 @@
     # Command "setup"
     subparser_list.append(setup_args.GetSetupArgParser(subparsers))
 
-    # Command "Delete"
+    # Command "delete"
     subparser_list.append(delete_args.GetDeleteArgParser(subparsers))
 
+    # Command "list"
+    subparser_list.append(list_args.GetListArgParser(subparsers))
+
     # Add common arguments.
     for subparser in subparser_list:
         acloud_common.AddCommonArguments(subparser)
@@ -407,10 +412,12 @@
             autoconnect=args.autoconnect,
             branch=args.branch,
             report_internal_ip=args.report_internal_ip)
-    elif args.which == CMD_DELETE:
+    elif args.which == delete_args.CMD_DELETE:
         report = delete.Run(args)
     elif args.which == CMD_CLEANUP:
         report = device_driver.Cleanup(cfg, args.expiration_mins)
+    elif args.which == list_args.CMD_LIST:
+        list_instances.Run(args)
     elif args.which == CMD_SSHKEY:
         report = device_driver.AddSshRsa(cfg, args.user, args.ssh_rsa_path)
     elif args.which == setup_args.CMD_SETUP: