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: