Add docker info to collect_logs.

BUG=1112422
TESTING=Manual testing. Unit testing.

Change-Id: I1621f9e788601bbd79d10a12fa599fa1b06169bf
diff --git a/src/moblab_common/feedback_connector.py b/src/moblab_common/feedback_connector.py
index 0dc0bd5..501b9f6 100644
--- a/src/moblab_common/feedback_connector.py
+++ b/src/moblab_common/feedback_connector.py
@@ -4,15 +4,16 @@
 # Use of this source code is governed by a BSD-style license that can be
 # found in the LICENSE file.
 import base64
+import logging
 import urllib.parse
 
-from moblab_common import moblab_info
-
 from datetime import datetime
 # pylint: disable=no-name-in-module, import-error
 from google.cloud import storage
 
-import logging
+from moblab_common import moblab_info
+from moblab_common.utils.cli import CopyFile
+
 
 class FeedbackPath():
   FEEDBACK = 'feedback'
@@ -30,13 +31,15 @@
     # TODO: Feedback will eventually be extended to include additional data beyond image and feedback text.
     raise NotImplementedError
 
-  def upload_feedback(self, contact_email=None, feedback=None, screenshot=None, filenames=[]):
+  def upload_feedback(self, contact_email=None, feedback=None, screenshot=None, files=[]):
     """Uploads image and feedback text to GCS.
 
     Args:
       contact_email: string email by which feedback submitter can be contacted.
       feedback: Text string of feedback.
       screenshot: Base 64 byte string representing a png screenshot image.
+      files: A list of CopyFile tuples (src, name) where src is the location of the
+      file on the local machine, and name is the desired name to upload as.
 
     Returns:
       A tuple (path, url) where path is the directory of the uploaded info, and
@@ -54,9 +57,9 @@
       feedback_blob = storage.Blob(feedback_dir_path + '/' + FeedbackPath.FEEDBACK, bucket)
       feedback_blob.upload_from_string("Contact email: " + contact_email + "\n" + feedback)
 
-    for filename in filenames:
-      filename_blob = storage.Blob(feedback_dir_path + filename, bucket)
-      filename_blob.upload_from_filename(filename)
+    for path, filename in files:
+      filename_blob = storage.Blob(feedback_dir_path + '/' + filename, bucket)
+      filename_blob.upload_from_filename(path)
 
     url = FeedbackPath.GCS_URL + '/' +\
           self.moblab_bucket_name + '/' +\
diff --git a/src/moblab_common/feedback_connector_unittest.py b/src/moblab_common/feedback_connector_unittest.py
index 452a204..7684898 100644
--- a/src/moblab_common/feedback_connector_unittest.py
+++ b/src/moblab_common/feedback_connector_unittest.py
@@ -32,7 +32,11 @@
     mock_moblab_info.get_serial_number.return_value = MOCK_MOBLAB_SERIAL_NO
     mock_datetime.utcnow.return_value = '2020-01-01 11:11:11'
 
-    screenshot_path, screenshot_url = self.feedback_connector.upload_feedback()
+    screenshot_path, screenshot_url = self.feedback_connector.upload_feedback(
+        contact_email='fake@email.com',
+        feedback='Feedback feedback feedback.',
+        files=[('/mock/path/to/tar', 'files.tgz')]
+    )
     assert(screenshot_path == 'feedback/4HBWUSIE15646458/2020-01-01 11:11:11')
     assert(screenshot_url ==
            'https://pantheon.corp.google.com/storage/browser/_details/moblab-mock-bucket/' +
diff --git a/src/moblab_common/monitoring/collect_logs.py b/src/moblab_common/monitoring/collect_logs.py
index e850698..3255fcd 100644
--- a/src/moblab_common/monitoring/collect_logs.py
+++ b/src/moblab_common/monitoring/collect_logs.py
@@ -5,10 +5,13 @@
 
 """Simple log collection script for Mob* Monitor"""
 
+import docker
 import glob
+import json
 import os
-import tempfile
+import re
 import shutil
+import tempfile
 
 from moblab_common import afe_connector
 from moblab_common import config_connector
@@ -26,44 +29,108 @@
     'moblab_id': '/home/moblab/.moblab_id',
 }
 
+DOCKER_DIR = 'docker'
+INSPECT_PREFIX = 'inspect_'
+DOCKER_PS_DETAILED = 'docker_ps_detailed'
+DOCKER_PS_SUCCINCT = 'docker_ps_succinct'
+DOCKER_SUCCINCT_KEYS = ["Command", "Created", "Id", "Image", "ImageID", "Names", "State", "Status"]
+
+
 def remove_old_tarballs():
+  """
+  Removes all files in the tmp dir where all log tarballs are created. This is a
+  backup, calls to collect_logs should clean up after themselves.
+  """
   paths = glob.iglob(os.path.join(TMPDIR, '%s*.tgz' % TMPDIR_PREFIX))
   for path in paths:
     os.remove(path)
 
+def _write_to_file(filename, content):
+  """
+  :param filename: complete path of file to write content to.
+  :param content: String content to be written to disk.
+  :return: None
+  """
+  with open(filename, 'w+') as f:
+    f.write(content)
+
+def _filtered_docker_container_info(docker_containers_info):
+  """
+  :param docker_containers_info: A list of dicts, each dict container a container's
+  info.
+  :return: A list of dicts, like the input, only with each dict filtered for target
+  keys.
+  """
+  succinct_containers_info = []
+  for container in docker_containers_info:
+    succinct_containers_info.append({ key: container.get(key, None) for key in DOCKER_SUCCINCT_KEYS})
+  return succinct_containers_info
+
+
+def _get_docker_container_details(write_dir):
+  """
+  :param filename: complete path of file to write content to.
+  :param content: String content to be written to disk.
+  :return: None
+  """
+  docker_api_client = docker.APIClient()
+  docker_client = docker.from_env()
+
+  cli.safe_mkdir(os.path.join(write_dir, DOCKER_DIR))
+
+  detailed_container_info = docker_api_client.containers()
+  _write_to_file(
+      os.path.join(write_dir, DOCKER_DIR, DOCKER_PS_DETAILED + '.log'),
+      json.dumps(detailed_container_info, indent=4, sort_keys=True)
+  )
+
+  succinct_container_info = _filtered_docker_container_info(detailed_container_info)
+  _write_to_file(
+      os.path.join(write_dir, DOCKER_DIR, DOCKER_PS_SUCCINCT + '.log'),
+      json.dumps(succinct_container_info, indent=4, sort_keys=True)
+  )
+
+  for container in docker_client.containers.list():
+    _write_to_file(
+        os.path.join(write_dir, DOCKER_DIR, INSPECT_PREFIX + container.name + '.log'),
+        json.dumps(docker_api_client.inspect_container(container.id), indent=4, sort_keys=True)
+    )
 
 def collect_logs():
+  """
+  Collects all available Moblab logs and docker configuration information.
+  :return: Path to tarball of collected logs and info.
+  """
   remove_old_tarballs()
   cli.safe_mkdir(TMPDIR)
-  tempdir = tempfile.mkdtemp(prefix=TMPDIR_PREFIX, dir=TMPDIR)
-  os.chmod(tempdir, 0o777)
 
-  try:
-    for name, path in LOG_DIRS.items():
-      if not os.path.exists(path):
-        continue
-      if os.path.isdir(path):
-        shutil.copytree(path, os.path.join(tempdir, name), ignore_dangling_symlinks=True)
-      else:
-        shutil.copyfile(path, os.path.join(tempdir, name), follow_symlinks=False)
+  with tempfile.TemporaryDirectory(prefix=TMPDIR_PREFIX, dir=TMPDIR) as tempdir:
+    os.chmod(tempdir, 0o777)
+    try:
+      for name, path in LOG_DIRS.items():
+        if not os.path.exists(path):
+          continue
+        if os.path.isdir(path):
+          shutil.copytree(path, os.path.join(tempdir, name), ignore_dangling_symlinks=True)
+        else:
+          shutil.copyfile(path, os.path.join(tempdir, name), follow_symlinks=False)
 
-    status_file = os.path.join(tempdir, 'mobmonitor_getstatus')
-    status = cli.run_command([
-            'curl', 'http://localhost:9991/GetStatus', '-o', status_file])
+      status_file = os.path.join(tempdir, 'mobmonitor_getstatus')
+      status = cli.run_command([
+              'curl', 'http://localhost:9991/GetStatus', '-o', status_file])
 
-    serialfile = os.path.join(tempdir, 'serialno')
-    with open(serialfile, 'w+') as f:
-      f.write(host_connector.HostServicesConnector().get_host_identifier())
+      serialfile = os.path.join(tempdir, 'serialno')
+      _write_to_file(serialfile, host_connector.HostServicesConnector().get_host_identifier())
 
-    afe = afe_connector.AFEConnector()
-    conf = config_connector.MoblabConfigConnector(afe)
-    conf.load_config()
-    configfile = os.path.join(tempdir, 'config')
-    with open(configfile, 'w+') as f:
-      f.write(str(conf.config))
+      afe = afe_connector.AFEConnector()
+      conf = config_connector.MoblabConfigConnector(afe)
+      conf.load_config()
+      configfile = os.path.join(tempdir, 'config')
+      _write_to_file(configfile, str(conf.config))
 
-  finally:
-    tarball = '%s.tgz' % tempdir
-    cli.create_tarball(tarball, tempdir)
-    cli.rm_dir(tempdir, ignore_missing=True)
+      _get_docker_container_details(tempdir)
+    finally:
+      tarball = '%s.tgz' % tempdir
+      cli.create_tarball(tarball, tempdir[len(TMPDIR + '/'):], TMPDIR)
+
   return tarball
diff --git a/src/moblab_common/monitoring/collect_logs_unittest.py b/src/moblab_common/monitoring/collect_logs_unittest.py
new file mode 100644
index 0000000..74466d2
--- /dev/null
+++ b/src/moblab_common/monitoring/collect_logs_unittest.py
@@ -0,0 +1,95 @@
+# -*- coding: utf-8 -*-
+# Copyright 2019 The Chromium OS Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+"""Unit tests for the collect logs utils"""
+import copy
+import json
+import sys
+import unittest
+
+from unittest import mock
+from unittest.mock import MagicMock #, PropertyMock
+
+MOCKED_DOCKER = MagicMock()
+sys.modules['docker'] = MOCKED_DOCKER
+sys.modules['moblab_common'] = MagicMock()
+sys.modules['moblab_common.utils'] = MagicMock()
+sys.modules['shutil'] = MagicMock()
+
+from collect_logs import collect_logs
+
+
+_NUM_MOCK_CONTAINERS = 10
+_MOCK_MOBLAB_ID = 'mock_moblab_id'
+_MOCK_CONTAINER_NAME = 'mock_container'
+_MOCK_CONTAINER_SUCCINCT = {
+    "Command" : 'a',
+    "Created" : 'b',
+    "Id" : 'c',
+    "Image" : 'd',
+    "ImageID" : 'e',
+    "Names" : 'f',
+    "State" : 'g',
+    "Status": 'h'
+}
+_MOCK_CONTAINER_DETAILED = copy.deepcopy(_MOCK_CONTAINER_SUCCINCT)
+_MOCK_CONTAINER_DETAILED.update({"OtherAttribute" : 'i'})
+_MOCK_CONTAINERS_SUCCINCT = [_MOCK_CONTAINER_SUCCINCT for _ in range(_NUM_MOCK_CONTAINERS)]
+_MOCK_CONTAINERS_DETAILED = [_MOCK_CONTAINER_DETAILED for _ in range(_NUM_MOCK_CONTAINERS)]
+
+_MOCK_CONTAINER_INSPECT_DICT = {
+    'attr1': 'value1',
+    'attr2': 'value2'
+}
+
+_MOCK_TEMPDIR_PATH = '/tmp/moblab_logs_xxxxxxxx/'
+
+class CollectLogsTest(unittest.TestCase):
+  """Testing the collect logs code."""
+
+  def _create_mock_api_client(self):
+    mock_api_client = MagicMock()
+    mock_api_client.containers.return_value = _MOCK_CONTAINERS_DETAILED
+    mock_api_client.inspect_container.return_value = _MOCK_CONTAINER_INSPECT_DICT
+    return mock_api_client
+
+  def _create_mock_docker_client(self):
+    mock_docker_client = MagicMock()
+    mock_container = MagicMock()
+    mock_container.name = _MOCK_CONTAINER_NAME
+    mock_docker_client.containers.list.return_value = [mock_container for _ in range(_NUM_MOCK_CONTAINERS)]
+    return mock_docker_client
+
+  def _create_mock_host_connector(self):
+    mock_host_connector = MagicMock()
+    mock_host_connector.get_host_identifier.return_value = _MOCK_MOBLAB_ID
+    return mock_host_connector
+
+  @mock.patch("os.chmod")
+  @mock.patch("tempfile.TemporaryDirectory")
+  @mock.patch("collect_logs.cli")
+  @mock.patch("collect_logs._write_to_file")
+  @mock.patch("collect_logs.docker.from_env")
+  @mock.patch("collect_logs.docker.APIClient")
+  @mock.patch("collect_logs.host_connector.HostServicesConnector")
+  def test_collect_logs(self, mock_host_services_connector, mock_docker_api_client, mock_docker_from_env, mock_write_to_file, mock_cli, mock_temp_dir, mock_chmod):
+    mock_temp_dir.return_value.__enter__.return_value = _MOCK_TEMPDIR_PATH
+    mock_docker_api_client.return_value = self._create_mock_api_client()
+    mock_docker_from_env.return_value = self._create_mock_docker_client()
+    mock_host_services_connector.return_value = self._create_mock_host_connector()
+
+    collect_logs()
+
+    self.assertEqual(mock_write_to_file.call_count, 4 + _NUM_MOCK_CONTAINERS)
+    mock_write_to_file.assert_has_calls([
+        mock.call(_MOCK_TEMPDIR_PATH + 'docker/inspect_mock_container.log', json.dumps(_MOCK_CONTAINER_INSPECT_DICT, indent=4, sort_keys=True)),
+        mock.call(_MOCK_TEMPDIR_PATH + 'docker/docker_ps_detailed.log', json.dumps(_MOCK_CONTAINERS_DETAILED, indent=4, sort_keys=True)),
+        mock.call(_MOCK_TEMPDIR_PATH + 'docker/docker_ps_succinct.log', json.dumps(_MOCK_CONTAINERS_SUCCINCT, indent=4, sort_keys=True)),
+        mock.call(_MOCK_TEMPDIR_PATH + 'config', mock.ANY),
+        mock.call(_MOCK_TEMPDIR_PATH + 'serialno', _MOCK_MOBLAB_ID)
+    ], any_order=True)
+    mock_cli.create_tarball.assert_called_once()
+
+if __name__ == "__main__":
+  unittest.main()
diff --git a/src/moblab_common/utils/cli.py b/src/moblab_common/utils/cli.py
index 373a48d..e6c887c 100644
--- a/src/moblab_common/utils/cli.py
+++ b/src/moblab_common/utils/cli.py
@@ -15,6 +15,11 @@
 
 LOGGER = logging.getLogger(__name__)
 
+# tuple for carrying holding arguments for a cp operation.
+# src: path to file to copy on local machine
+# name: filename of the copy
+CopyFile = collections.namedtuple('CopyFile', ['src', 'name'])
+
 class RunCommandError(subprocess.CalledProcessError):
     def __init__(self, returncode, cmd):
         super(RunCommandError, self).__init__(returncode, cmd)
@@ -63,14 +68,20 @@
     return run_command(cmd, log=True, error_code_ok=error_code_ok,
             shell=shell)
 
-def create_tarball(tarball_name, directory):
+def create_tarball(tarball_name, directory, starting_directory=None):
     """Creates a gzipped tar archive
 
     Args:
         tarball_name: string name for the tarball
         directory: directory to archive the contents of
+        starting_directory: context directory to find directory within (
+          useful for hiding undesired parent directory structures )
     """
-    run_command(['tar', '-czf', tarball_name, directory])
+    args = ['-czf', tarball_name, directory]
+    if starting_directory:
+      args = ['-C', starting_directory] + args
+    args = ['tar'] + args
+    run_command(args)
 
 def safe_mkdir(path):
     """Create a directory and any parent directories if they don't exist.
diff --git a/src/mobmonitor/mobmonitor.py b/src/mobmonitor/mobmonitor.py
index 8a10da8..72f0889 100644
--- a/src/mobmonitor/mobmonitor.py
+++ b/src/mobmonitor/mobmonitor.py
@@ -15,15 +15,14 @@
 import argparse
 
 from cherrypy.lib.static import serve_file
+
 from checkfile import manager as cf_manager
-
 from diagnostic_checks import manager as dc_manager
-
 from moblab_common import afe_connector
-from moblab_common.monitoring import collect_logs
 from moblab_common import config_connector
 from moblab_common import feedback_connector
-
+from moblab_common.monitoring import collect_logs
+from moblab_common.utils.cli import CopyFile
 
 
 STATICDIR = '/etc/moblab/mobmonitor/static/'
@@ -131,8 +130,11 @@
     conf = config_connector.MoblabConfigConnector(afe)
     conf.load_config()
     conn = feedback_connector.MoblabFeedbackConnector(conf.get_cloud_bucket())
-    tarfile = collect_logs.collect_logs()
-    feedback_dir_path, url = conn.upload_feedback(filenames=[tarfile])
+    tarfile_path = collect_logs.collect_logs()
+    feedback_dir_path, url = conn.upload_feedback(
+        # upload tar as a *.tgz without exposing any of the Moblab dir structure
+        files=[CopyFile(src=tarfile_path, name=tarfile_path.split('/')[-1])]
+    )
     return ("Your logs have been uploaded to your Google Cloud bucket "
             "<a href=\"https://console.developers.google.com/storage/%s/%s\">Link to Logs</a>" % (conf.get_cloud_bucket(), feedback_dir_path))