1# Copyright 2017 The Chromium OS Authors. All rights reserved.
2# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
4
5import logging
6import os
7import time
8
9import common
10
11from autotest_lib.client.bin import utils
12from autotest_lib.client.common_lib import error
13from autotest_lib.site_utils.lxc import config as lxc_config
14from autotest_lib.site_utils.lxc import constants
15from autotest_lib.site_utils.lxc import lxc
16from autotest_lib.site_utils.lxc import utils as lxc_utils
17from autotest_lib.site_utils.lxc.cleanup_if_fail import cleanup_if_fail
18from autotest_lib.site_utils.lxc.base_image import BaseImage
19from autotest_lib.site_utils.lxc.constants import \
20    CONTAINER_POOL_METRICS_PREFIX as METRICS_PREFIX
21from autotest_lib.site_utils.lxc.container import Container
22from autotest_lib.site_utils.lxc.container_factory import ContainerFactory
23
24try:
25    from chromite.lib import metrics
26    from infra_libs import ts_mon
27except ImportError:
28    import mock
29    metrics = utils.metrics_mock
30    ts_mon = mock.Mock()
31
32
33class ContainerBucket(object):
34    """A wrapper class to interact with containers in a specific container path.
35    """
36
37    def __init__(self, container_path=constants.DEFAULT_CONTAINER_PATH,
38                 base_name=constants.BASE, container_factory=None):
39        """Initialize a ContainerBucket.
40
41        @param container_path: Path to the directory used to store containers.
42                               Default is set to AUTOSERV/container_path in
43                               global config.
44        @param base_name: Name of the base container image. Used to initialize a
45                          ContainerFactory unless one is provided via the
46                          arguments. Defaults to value set via
47                          AUTOSERV/container_base_name in global config.
48        @param container_factory: A factory for creating Containers.
49        """
50        self.container_path = os.path.realpath(container_path)
51        if container_factory is not None:
52            self._factory = container_factory
53        else:
54            # Pass in the container path so that the bucket is hermetic (i.e. so
55            # that if the container path is customized, the base image doesn't
56            # fall back to using the default container path).
57            try:
58                base_image_ok = True
59                container = BaseImage(self.container_path, base_name).get()
60            except error.ContainerError:
61                base_image_ok = False
62                raise
63            finally:
64                metrics.Counter(METRICS_PREFIX + '/base_image',
65                                field_spec=[ts_mon.BooleanField('corrupted')]
66                                ).increment(
67                                    fields={'corrupted': not base_image_ok})
68            self._factory = ContainerFactory(
69                base_container=container,
70                lxc_path=self.container_path)
71        self.container_cache = {}
72
73
74    def get_all(self, force_update=False):
75        """Get details of all containers.
76
77        Retrieves all containers owned by the bucket.  Note that this doesn't
78        include the base container, or any containers owned by the container
79        pool.
80
81        @param force_update: Boolean, ignore cached values if set.
82
83        @return: A dictionary of all containers with detailed attributes,
84                 indexed by container name.
85        """
86        logging.debug("Fetching all extant LXC containers")
87        info_collection = lxc.get_container_info(self.container_path)
88        if force_update:
89          logging.debug("Clearing cached container info")
90        containers = {} if force_update else self.container_cache
91        for info in info_collection:
92            if info["name"] in containers:
93                continue
94            container = Container.create_from_existing_dir(self.container_path,
95                                                           **info)
96            # Active containers have an ID.  Zygotes and base containers, don't.
97            if container.id is not None:
98                containers[container.id] = container
99        self.container_cache = containers
100        return containers
101
102
103    def get_container(self, container_id):
104        """Get a container with matching name.
105
106        @param container_id: ID of the container.
107
108        @return: A container object with matching name. Returns None if no
109                 container matches the given name.
110        """
111        logging.debug("Fetching LXC container with id %s", container_id)
112        if container_id in self.container_cache:
113            logging.debug("Found container %s in cache", container_id)
114            return self.container_cache[container_id]
115
116        container = self.get_all().get(container_id, None)
117        if None == container:
118          logging.debug("Could not find container %s", container_id)
119        return container
120
121
122    def exist(self, container_id):
123        """Check if a container exists with the given name.
124
125        @param container_id: ID of the container.
126
127        @return: True if the container with the given ID exists, otherwise
128                 returns False.
129        """
130        return self.get_container(container_id) != None
131
132
133    def destroy_all(self):
134        """Destroy all containers, base must be destroyed at the last.
135        """
136        containers = self.get_all().values()
137        for container in sorted(
138                containers, key=lambda n: 1 if n.name == constants.BASE else 0):
139            key = container.id
140            logging.info('Destroy container %s.', container.name)
141            container.destroy()
142            del self.container_cache[key]
143
144    def scrub_container_location(self, name,
145                                 timeout=constants.LXC_SCRUB_TIMEOUT):
146        """Destroy a possibly-nonexistent, possibly-malformed container.
147
148        This exists to clean up an unreachable container which may or may not
149        exist and is probably but not definitely malformed if it does exist. It
150        is accordingly scorched-earth and force-destroys the container with all
151        associated snapshots. Also accordingly, this will not raise an
152        exception if the destruction fails.
153
154        @param name: ID of the container.
155        @param timeout: Seconds to wait for removal.
156
157        @returns: CmdResult object from the shell command
158        """
159        logging.debug(
160            "Force-destroying container %s if it exists, with timeout %s sec",
161            name, timeout)
162        try:
163          result = lxc_utils.destroy(
164              self.container_path, name,
165              force=True, snapshots=True, ignore_status=True, timeout=timeout
166          )
167        except error.CmdTimeoutError:
168          logging.warning("Force-destruction of container %s timed out.", name)
169        logging.debug("Force-destruction exit code %s", result.exit_status)
170        return result
171
172
173
174    @metrics.SecondsTimerDecorator(
175        '%s/setup_test_duration' % constants.STATS_KEY)
176    @cleanup_if_fail()
177    def setup_test(self, container_id, job_id, server_package_url, result_path,
178                   control=None, skip_cleanup=False, job_folder=None,
179                   dut_name=None, isolate_hash=None):
180        """Setup test container for the test job to run.
181
182        The setup includes:
183        1. Install autotest_server package from given url.
184        2. Copy over local shadow_config.ini.
185        3. Mount local site-packages.
186        4. Mount test result directory.
187
188        TODO(dshi): Setup also needs to include test control file for autoserv
189                    to run in container.
190
191        @param container_id: ID to assign to the test container.
192        @param job_id: Job id for the test job to run in the test container.
193        @param server_package_url: Url to download autotest_server package.
194        @param result_path: Directory to be mounted to container to store test
195                            results.
196        @param control: Path to the control file to run the test job. Default is
197                        set to None.
198        @param skip_cleanup: Set to True to skip cleanup, used to troubleshoot
199                             container failures.
200        @param job_folder: Folder name of the job, e.g., 123-debug_user.
201        @param dut_name: Name of the dut to run test, used as the hostname of
202                         the container. Default is None.
203        @param isolate_hash: String key to look up the isolate package needed
204                             to run test. Default is None, supersedes
205                             server_package_url if present.
206        @return: A Container object for the test container.
207
208        @raise ContainerError: If container does not exist, or not running.
209        """
210        start_time = time.time()
211
212        if not os.path.exists(result_path):
213            raise error.ContainerError('Result directory does not exist: %s',
214                                       result_path)
215        result_path = os.path.abspath(result_path)
216
217        # Save control file to result_path temporarily. The reason is that the
218        # control file in drone_tmp folder can be deleted during scheduler
219        # restart. For test not using SSP, the window between test starts and
220        # control file being picked up by the test is very small (< 2 seconds).
221        # However, for tests using SSP, it takes around 1 minute before the
222        # container is setup. If scheduler is restarted during that period, the
223        # control file will be deleted, and the test will fail.
224        if control:
225            control_file_name = os.path.basename(control)
226            safe_control = os.path.join(result_path, control_file_name)
227            utils.run('cp %s %s' % (control, safe_control))
228
229        # Create test container from the base container.
230        container = self._factory.create_container(container_id)
231
232        # Deploy server side package
233        if isolate_hash:
234          container.install_ssp_isolate(isolate_hash)
235        else:
236          container.install_ssp(server_package_url)
237
238        deploy_config_manager = lxc_config.DeployConfigManager(container)
239        deploy_config_manager.deploy_pre_start()
240
241        # Copy over control file to run the test job.
242        if control:
243            container.install_control_file(safe_control)
244
245        # Use a pre-packaged Trusty-compatible Autotest site_packages
246        # instead if it exists.  crbug.com/1013241
247        if os.path.exists(constants.TRUSTY_SITE_PACKAGES_PATH):
248            mount_entries = [(constants.TRUSTY_SITE_PACKAGES_PATH,
249                              constants.CONTAINER_SITE_PACKAGES_PATH,
250                              True)]
251        else:
252            mount_entries = [(constants.SITE_PACKAGES_PATH,
253                              constants.CONTAINER_SITE_PACKAGES_PATH,
254                              True)]
255        mount_entries.extend([
256                (result_path,
257                 os.path.join(constants.RESULT_DIR_FMT % job_folder),
258                 False),
259        ])
260
261        # Update container config to mount directories.
262        for source, destination, readonly in mount_entries:
263            container.mount_dir(source, destination, readonly)
264
265        # Update file permissions.
266        # TODO(dshi): crbug.com/459344 Skip following action when test container
267        # can be unprivileged container.
268        autotest_path = os.path.join(
269                container.rootfs,
270                constants.CONTAINER_AUTOTEST_DIR.lstrip(os.path.sep))
271        utils.run('sudo chown -R root "%s"' % autotest_path)
272        utils.run('sudo chgrp -R root "%s"' % autotest_path)
273
274        container.start(wait_for_network=True)
275        deploy_config_manager.deploy_post_start()
276
277        # Update the hostname of the test container to be `dut-name`.
278        # Some TradeFed tests use hostname in test results, which is used to
279        # group test results in dashboard. The default container name is set to
280        # be the name of the folder, which is unique (as it is composed of job
281        # id and timestamp. For better result view, the container's hostname is
282        # set to be a string containing the dut hostname.
283        if dut_name:
284            container.set_hostname(constants.CONTAINER_UTSNAME_FORMAT %
285                                   dut_name.replace('.', '-'))
286
287        container.modify_import_order()
288
289        container.verify_autotest_setup(job_folder)
290
291        logging.debug('Test container %s is set up.', container.name)
292        return container
293