• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1#!/usr/bin/env python3
2# Copyright 2021 The ChromiumOS Authors
3# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
5
6# Usage:
7#
8# To get an interactive shell for development:
9#   ./tools/dev_container
10#
11# To run a command in the container, e.g. to run presubmits:
12#   ./tools/dev_container ./tools/presubmit
13#
14# The state of the container (including build artifacts) are preserved between
15# calls. To stop the container call:
16#   ./tools/dev_container --stop
17#
18# The dev container can also be called with a fresh container for each call that
19# is cleaned up afterwards (e.g. when run by Kokoro):
20#
21#   ./tools/dev_container --hermetic CMD
22#
23# There's an alternative container which can be used to test crosvm in crOS tree.
24# It can be launched with:
25#   ./tools/dev_container --cros
26
27import argparse
28from argh import arg  # type: ignore
29from impl.common import (
30    chdir,
31    cmd,
32    cros_repo_root,
33    CROSVM_ROOT,
34    is_cros_repo,
35    is_kiwi_repo,
36    kiwi_repo_root,
37    quoted,
38    run_main,
39)
40from typing import Optional, Tuple, List
41import getpass
42import shutil
43import sys
44import unittest
45import os
46import zlib
47
48DEV_CONTAINER_NAME = (
49    f"crosvm_dev_{getpass.getuser()}_{zlib.crc32(os.path.realpath(__file__).encode('utf-8')):x}"
50)
51CROS_CONTAINER_NAME = (
52    f"crosvm_cros_{getpass.getuser()}_{zlib.crc32(os.path.realpath(__file__).encode('utf-8')):x}"
53)
54
55DEV_IMAGE_NAME = "gcr.io/crosvm-infra/crosvm_dev_user"
56CROS_IMAGE_NAME = "gcr.io/crosvm-infra/crosvm_cros_cloudbuild"
57DEV_IMAGE_VERSION = (CROSVM_ROOT / "tools/impl/dev_container/version").read_text().strip()
58
59CACHE_DIR = os.environ.get("CROSVM_CONTAINER_CACHE", None)
60
61DOCKER_ARGS = [
62    # Share cache dir
63    f"--volume {CACHE_DIR}:/cache:rw" if CACHE_DIR else None,
64    # Use tmpfs in the container for faster performance.
65    "--mount type=tmpfs,destination=/tmp",
66    # KVM is required to run a VM for testing.
67    "--device /dev/kvm",
68]
69
70if sys.platform == "linux":
71    DOCKER_ARGS.extend(
72        [
73            f"--env OUTSIDE_UID={os.getuid()}",
74            f"--env OUTSIDE_GID={os.getgid()}",
75            f"--env TERM={os.environ.get('TERM', 'xterm-256color')}",
76        ]
77    )
78
79PODMAN_ARGS = [
80    # Share cache dir
81    f"--volume {CACHE_DIR}:/cache:rw" if CACHE_DIR else None,
82    # Use tmpfs in the container for faster performance.
83    "--mount type=tmpfs,destination=/tmp",
84    # KVM is required to run a VM for testing.
85    "--device /dev/kvm",
86    # Allow access to group permissions of the user (e.g. for kvm access).
87    "--group-add keep-groups",
88]
89
90PRIVILEGED_ARGS = [
91    # Share devices and syslog
92    "--volume /dev/log:/dev/log",
93    "--device /dev/net/tun",
94    "--device /dev/vhost-net",
95    "--device /dev/vhost-vsock",
96    # For plugin process jail
97    "--mount type=tmpfs,destination=/var/empty",
98]
99
100
101PODMAN_IS_DEFAULT = shutil.which("docker") == None
102
103
104def container_name(cros: bool):
105    if cros:
106        return CROS_CONTAINER_NAME
107    else:
108        return DEV_CONTAINER_NAME
109
110
111def container_revision(docker: cmd, container_id: str):
112    image = docker("container inspect -f {{.Config.Image}}", container_id).stdout()
113    parts = image.split(":")
114    assert len(parts) == 2, f"Invalid image name {image}"
115    return parts[1]
116
117
118def container_id(docker: cmd, cros: bool):
119    return docker(f"ps -a -q -f name={container_name(cros)}").stdout()
120
121
122def container_is_running(docker: cmd, cros: bool):
123    return bool(docker(f"ps -q -f name={container_name(cros)}").stdout())
124
125
126def delete_container(docker: cmd, cros: bool):
127    cid = container_id(docker, cros)
128    if cid:
129        print(f"Deleting dev-container {cid}.")
130        docker("rm -f", cid).fg(quiet=True)
131        return True
132    return False
133
134
135def workspace_mount_args(cros: bool):
136    """
137    Returns arguments for mounting the crosvm sources to /workspace.
138
139    In ChromeOS checkouts the crosvm repo uses a symlink or worktree checkout, which links to a
140    different folder in the ChromeOS checkout. So we need to mount the whole CrOS checkout.
141    """
142    if cros:
143        return ["--workdir /home/crosvmdev/chromiumos/src/platform/crosvm"]
144    elif is_cros_repo():
145        return [
146            f"--volume {quoted(cros_repo_root())}:/workspace:rw",
147            "--workdir /workspace/src/platform/crosvm",
148        ]
149    elif is_kiwi_repo():
150        return [
151            f"--volume {quoted(kiwi_repo_root())}:/workspace:rw",
152            # We override /scratch because we run out of memory if we use memory to back the
153            # `/scratch` mount point.
154            f"--volume {quoted(kiwi_repo_root())}/scratch:/scratch/cargo_target:rw",
155            "--workdir /workspace/platform/crosvm",
156        ]
157    else:
158        return [
159            f"--volume {quoted(CROSVM_ROOT)}:/workspace:rw",
160        ]
161
162
163def ensure_container_is_alive(docker: cmd, docker_args: List[Optional[str]], cros: bool):
164    cid = container_id(docker, cros)
165    if cid and not container_is_running(docker, cros):
166        print("Existing container is not running.")
167        delete_container(docker, cros)
168    elif cid and not cros and container_revision(docker, cid) != DEV_IMAGE_VERSION:
169        print(f"New image is available.")
170        delete_container(docker, cros)
171
172    if not container_is_running(docker, cros):
173        # Run neverending sleep to keep container alive while we 'docker exec' commands.
174        docker(
175            f"run --detach --name {container_name(cros)}",
176            *docker_args,
177            "sleep infinity",
178        ).fg(quiet=True)
179        cid = container_id(docker, cros)
180        print(f"Started container ({cid}).")
181    else:
182        cid = container_id(docker, cros)
183        print(f"Using existing container ({cid}).")
184    return cid
185
186
187@arg("command", nargs=argparse.REMAINDER)
188def main(
189    command: Tuple[str, ...],
190    stop: bool = False,
191    clean: bool = False,
192    hermetic: bool = False,
193    interactive: bool = False,
194    podman: bool = PODMAN_IS_DEFAULT,
195    self_test: bool = False,
196    pull: bool = False,
197    unprivileged: bool = False,
198    cros: bool = False,
199):
200    chdir(CROSVM_ROOT)
201
202    if cros and unprivileged:
203        print("ERROR: crOS container must be run in privileged mode")
204        sys.exit(1)
205
206    if unprivileged:
207        print("WARNING: Running dev_container with --unprivileged is a work in progress.")
208        print("Not all tests are expected to pass.")
209        print()
210
211    docker_args = [
212        *workspace_mount_args(cros),
213        *(PRIVILEGED_ARGS if not unprivileged else []),
214    ]
215    if podman:
216        print("WARNING: Running dev_container with podman is experimental.")
217        print("It is strongly recommended to use docker.")
218        print()
219        docker = cmd("podman")
220        docker_args += [*PODMAN_ARGS]
221    else:
222        docker = cmd("docker")
223        docker_args += [
224            "--privileged" if not unprivileged else None,
225            *DOCKER_ARGS,
226        ]
227
228    if cros:
229        docker_args.append(CROS_IMAGE_NAME)
230    else:
231        docker_args.append(DEV_IMAGE_NAME + ":" + DEV_IMAGE_VERSION)
232
233    if self_test:
234        TestDevContainer.docker = docker
235        suite = unittest.defaultTestLoader.loadTestsFromTestCase(TestDevContainer)
236        unittest.TextTestRunner().run(suite)
237        return
238
239    if stop:
240        if not delete_container(docker, cros):
241            print(f"container is not running.")
242        return
243
244    if clean:
245        delete_container(docker, cros)
246
247    if pull:
248        if cros:
249            docker("pull", CROS_IMAGE_NAME).fg()
250        else:
251            docker("pull", f"gcr.io/crosvm-infra/crosvm_dev:{DEV_IMAGE_VERSION}").fg()
252            docker("pull", f"gcr.io/crosvm-infra/crosvm_dev_user:{DEV_IMAGE_VERSION}").fg()
253        return
254
255    # If a command is provided run non-interactive unless explicitly asked for.
256    tty_args = []
257    if not command or interactive:
258        if not sys.stdin.isatty():
259            raise Exception("Trying to run an interactive session in a non-interactive terminal.")
260        tty_args = ["--interactive", "--tty"]
261    elif sys.stdin.isatty():
262        # Even if run non-interactively, we do want to pass along a tty for proper output.
263        tty_args = ["--tty"]
264
265    # Start an interactive shell by default
266    if hermetic:
267        # cmd is passed to entrypoint
268        quoted_cmd = list(map(quoted, command))
269        docker(f"run --rm", *tty_args, *docker_args, *quoted_cmd).fg()
270    else:
271        # cmd is executed directly
272        cid = ensure_container_is_alive(docker, docker_args, cros)
273        if podman:
274            if not command:
275                command = ("/bin/bash",)
276        else:
277            if not command:
278                command = ("/tools/entrypoint.sh",)
279            else:
280                command = ("/tools/entrypoint.sh",) + tuple(command)
281        quoted_cmd = list(map(quoted, command))
282        docker("exec", *tty_args, cid, *quoted_cmd).fg()
283
284
285class TestDevContainer(unittest.TestCase):
286    """
287    Runs live tests using the docker service.
288
289    Note: This test is not run by health-check since it cannot be run inside the
290    container. It is run by infra/recipes/health_check.py before running health checks.
291    """
292
293    docker: cmd
294    docker_args = [
295        *workspace_mount_args(cros=False),
296        *DOCKER_ARGS,
297    ]
298
299    def setUp(self):
300        # Start with a stopped container for each test.
301        delete_container(self.docker, cros=False)
302
303    def test_stopped_container(self):
304        # Create but do not run a new container.
305        self.docker(
306            f"create --name {DEV_CONTAINER_NAME}", *self.docker_args, "sleep infinity"
307        ).stdout()
308        self.assertTrue(container_id(self.docker, cros=False))
309        self.assertFalse(container_is_running(self.docker, cros=False))
310
311    def test_container_reuse(self):
312        cid = ensure_container_is_alive(self.docker, self.docker_args, cros=False)
313        cid2 = ensure_container_is_alive(self.docker, self.docker_args, cros=False)
314        self.assertEqual(cid, cid2)
315
316    def test_handling_of_stopped_container(self):
317        cid = ensure_container_is_alive(self.docker, self.docker_args, cros=False)
318        self.docker("kill", cid).fg()
319
320        # Make sure we can get back into a good state and execute commands.
321        ensure_container_is_alive(self.docker, self.docker_args, cros=False)
322        self.assertTrue(container_is_running(self.docker, cros=False))
323        main(("true",))
324
325
326if __name__ == "__main__":
327    run_main(main)
328