#!/usr/bin/env python3 # Copyright 2021 The ChromiumOS Authors # Use of this source code is governed by a BSD-style license that can be # found in the LICENSE file. # Usage: # # To get an interactive shell for development: # ./tools/dev_container # # To run a command in the container, e.g. to run presubmits: # ./tools/dev_container ./tools/presubmit # # The state of the container (including build artifacts) are preserved between # calls. To stop the container call: # ./tools/dev_container --stop # # The dev container can also be called with a fresh container for each call that # is cleaned up afterwards (e.g. when run by Kokoro): # # ./tools/dev_container --hermetic CMD # # There's an alternative container which can be used to test crosvm in crOS tree. # It can be launched with: # ./tools/dev_container --cros import argparse from pathlib import Path import shutil from impl.util import ( add_common_args, confirm, cros_repo_root, CROSVM_ROOT, is_cros_repo, is_kiwi_repo, kiwi_repo_root, is_aosp_repo, aosp_repo_root, ) from impl.command import ( chdir, cmd, quoted, ) from typing import Optional, List import getpass import sys import unittest import os import zlib DEV_CONTAINER_NAME = ( f"crosvm_dev_{getpass.getuser()}_{zlib.crc32(os.path.realpath(__file__).encode('utf-8')):x}" ) CROS_CONTAINER_NAME = ( f"crosvm_cros_{getpass.getuser()}_{zlib.crc32(os.path.realpath(__file__).encode('utf-8')):x}" ) DEV_IMAGE_NAME = "gcr.io/crosvm-infra/crosvm_dev" CROS_IMAGE_NAME = "gcr.io/crosvm-infra/crosvm_cros_cloudbuild" DEV_IMAGE_VERSION = (CROSVM_ROOT / "tools/impl/dev_container/version").read_text().strip() CACHE_DIR = os.environ.get("CROSVM_CONTAINER_CACHE", None) COMMON_ARGS = [ # Share cache dir f"--volume {CACHE_DIR}:/cache:rw" if CACHE_DIR else None, # Use tmpfs in the container for faster performance. "--mount type=tmpfs,destination=/tmp", # KVM is required to run a VM for testing. "--device /dev/kvm" if Path("/dev/kvm").is_char_device() else None, # Enable terminal colors f"--env TERM={os.environ.get('TERM', 'xterm-256color')}", ] DOCKER_ARGS = [ *COMMON_ARGS, ] PODMAN_ARGS = [ *COMMON_ARGS, # Allow access to group permissions of the user (e.g. for kvm access). "--group-add keep-groups" if os.name == "posix" else None, # Increase number of PIDs the container can spawn (we run a lot of test processes in parallel) "--pids-limit=4096" if os.name == "posix" else None, ] # Environment variables to pass through to the container if they are specified. ENV_PASSTHROUGH = [ "NEXTEST_PROFILE", "http_proxy", "https_proxy", ] def machine_is_running(docker: cmd): machine_state = docker("machine info").stdout() return "MachineState: Running" in machine_state def container_name(cros: bool): if cros: return CROS_CONTAINER_NAME else: return DEV_CONTAINER_NAME def container_revision(docker: cmd, container_id: str): image = docker("container inspect -f {{.Config.Image}}", container_id).stdout() parts = image.split(":") assert len(parts) == 2, f"Invalid image name {image}" return parts[1] def container_id(docker: cmd, cros: bool): return docker(f"ps -a -q -f name={container_name(cros)}").stdout() def container_is_running(docker: cmd, cros: bool): return bool(docker(f"ps -q -f name={container_name(cros)}").stdout()) def delete_container(docker: cmd, cros: bool): cid = container_id(docker, cros) if cid: print(f"Deleting dev-container {cid}.") docker("rm -f", cid).fg(quiet=True) return True return False def workspace_mount_args(cros: bool): """ Returns arguments for mounting the crosvm sources to /workspace. In ChromeOS checkouts the crosvm repo uses a symlink or worktree checkout, which links to a different folder in the ChromeOS checkout. So we need to mount the whole CrOS checkout. """ if cros: return ["--workdir /home/crosvmdev/chromiumos/src/platform/crosvm"] elif is_cros_repo(): return [ f"--volume {quoted(cros_repo_root())}:/workspace:rw", "--workdir /workspace/src/platform/crosvm", ] elif is_kiwi_repo(): return [ f"--volume {quoted(kiwi_repo_root())}:/workspace:rw", # We override /scratch because we run out of memory if we use memory to back the # `/scratch` mount point. f"--volume {quoted(kiwi_repo_root())}/scratch:/scratch/cargo_target:rw", "--workdir /workspace/platform/crosvm", ] elif is_aosp_repo(): return [ f"--volume {quoted(aosp_repo_root())}:/workspace:rw", "--workdir /workspace/external/crosvm", ] else: return [ f"--volume {quoted(CROSVM_ROOT)}:/workspace:rw", ] def ensure_container_is_alive(docker: cmd, docker_args: List[Optional[str]], cros: bool): cid = container_id(docker, cros) if cid and not container_is_running(docker, cros): print("Existing container is not running.") delete_container(docker, cros) elif cid and not cros and container_revision(docker, cid) != DEV_IMAGE_VERSION: print(f"New image is available.") delete_container(docker, cros) if not container_is_running(docker, cros): # Run neverending sleep to keep container alive while we 'docker exec' commands. print(f"Starting container...") docker( f"run --detach --name {container_name(cros)}", *docker_args, "sleep infinity", ).fg(quiet=False) cid = container_id(docker, cros) else: cid = container_id(docker, cros) print(f"Using existing container ({cid}).") return cid def validate_podman(podman: cmd): graph_driver_name = podman("info --format={{.Store.GraphDriverName}}").stdout() config_file_name = podman("info --format={{.Store.ConfigFile}}").stdout() if graph_driver_name == "vfs": print("You are using vfs as a storage driver. This will be extremely slow.") print("Using the overlay driver is strongly recommended.") print("Note: This will delete all existing podman images and containers.") if confirm(f"Do you want me to update your config in {config_file_name}?"): podman("system reset -f").fg() with open(config_file_name, "a") as config_file: print("[storage]", file=config_file) print('driver = "overlay"', file=config_file) if os.name == "posix": username = os.environ["USER"] subuids = Path("/etc/subuid").read_text() if not username in subuids: print("Rootless podman requires subuid's to be set up for your user.") usermod = cmd( "sudo usermod --add-subuids 900000-965535 --add-subgids 900000-965535", username ) print("I can fix that by running:", usermod) if confirm("Ok?"): usermod.fg() podman("system migrate").fg() def main(argv: List[str]): parser = argparse.ArgumentParser() add_common_args(parser) parser.add_argument("--stop", action="store_true") parser.add_argument("--clean", action="store_true") parser.add_argument("--hermetic", action="store_true") parser.add_argument("--no-interactive", action="store_true") parser.add_argument("--use-docker", action="store_true") parser.add_argument("--self-test", action="store_true") parser.add_argument("--pull", action="store_true") parser.add_argument("--cros", action="store_true") parser.add_argument("command", nargs=argparse.REMAINDER) args = parser.parse_args(argv) chdir(CROSVM_ROOT) if CACHE_DIR: Path(CACHE_DIR).mkdir(exist_ok=True) has_docker = shutil.which("docker") != None has_podman = shutil.which("podman") != None if not has_podman and not has_docker: raise Exception("Please install podman (or docker) to use the dev container.") use_docker = args.use_docker if has_docker and not has_podman: use_docker = True # cros container only works in docker if args.cros: use_docker = True if use_docker: print( "WARNING: Running dev_container with docker may cause root-owned files to be created." ) print("Use podman to prevent this.") print() docker = cmd("docker") docker_args = [ *DOCKER_ARGS, *workspace_mount_args(args.cros), ] else: docker = cmd("podman") # On windows, podman uses wsl vm. start the default podman vm for the rest of the script # to work properly. if os.name == "nt" and not machine_is_running(docker): print("Starting podman default machine.") docker("machine start").fg(quiet=True) docker_args = [ *PODMAN_ARGS, *workspace_mount_args(args.cros), ] validate_podman(docker) if args.cros: docker_args.append("--privileged") # cros container requires privileged container docker_args.append(CROS_IMAGE_NAME) else: docker_args.append(DEV_IMAGE_NAME + ":" + DEV_IMAGE_VERSION) # Add environment variables to command line exec_args: List[str] = [] for key in ENV_PASSTHROUGH: value = os.environ.get(key) if value is not None: exec_args.append("--env") exec_args.append(f"{key}={quoted(value)}") if args.self_test: TestDevContainer.docker = docker suite = unittest.defaultTestLoader.loadTestsFromTestCase(TestDevContainer) unittest.TextTestRunner().run(suite) return if args.stop: if not delete_container(docker, args.cros): print(f"container is not running.") return if args.clean: delete_container(docker, args.cros) if args.pull: if args.cros: docker("pull", CROS_IMAGE_NAME).fg() else: docker("pull", f"gcr.io/crosvm-infra/crosvm_dev:{DEV_IMAGE_VERSION}").fg() return command = args.command # Default to interactive mode if a tty is present. tty_args: List[str] = [] if sys.stdin.isatty(): tty_args += ["--tty"] if not args.no_interactive: tty_args += ["--interactive"] # Start an interactive shell by default if args.hermetic: # cmd is passed to entrypoint quoted_cmd = list(map(quoted, command)) docker(f"run --rm", *tty_args, *docker_args, *exec_args, *quoted_cmd).fg() else: # cmd is executed directly cid = ensure_container_is_alive(docker, docker_args, args.cros) if not command: command = ("/bin/bash",) quoted_cmd = list(map(quoted, command)) docker("exec", *tty_args, *exec_args, cid, *quoted_cmd).fg() class TestDevContainer(unittest.TestCase): """ Runs live tests using the docker service. Note: This test is not run by health-check since it cannot be run inside the container. It is run by infra/recipes/health_check.py before running health checks. """ docker: cmd docker_args = [ *workspace_mount_args(cros=False), *DOCKER_ARGS, ] def setUp(self): # Start with a stopped container for each test. delete_container(self.docker, cros=False) def test_stopped_container(self): # Create but do not run a new container. self.docker( f"create --name {DEV_CONTAINER_NAME}", *self.docker_args, "sleep infinity" ).stdout() self.assertTrue(container_id(self.docker, cros=False)) self.assertFalse(container_is_running(self.docker, cros=False)) def test_container_reuse(self): cid = ensure_container_is_alive(self.docker, self.docker_args, cros=False) cid2 = ensure_container_is_alive(self.docker, self.docker_args, cros=False) self.assertEqual(cid, cid2) def test_handling_of_stopped_container(self): cid = ensure_container_is_alive(self.docker, self.docker_args, cros=False) self.docker("kill", cid).fg() # Make sure we can get back into a good state and execute commands. ensure_container_is_alive(self.docker, self.docker_args, cros=False) self.assertTrue(container_is_running(self.docker, cros=False)) main(["true"]) if __name__ == "__main__": main(sys.argv[1:])