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