crosvm/tools/dev_container
Zihan Chen 988858b66b infra: Add build_chromeos_container builder
Add a new builder to build crosvm in crOS tree, and all the
depencies of this new builder.

BUG=b:240692674
TESTED=led get-builder luci.crosvm.ci:chromeos_amd64-generic | led edit-cr-cl https://chromium-review.googlesource.com/c/crosvm/crosvm/+/3966928 | led edit-recipe-bundle | led edit -r build_chromeos_hatch | led launch

Change-Id: Id2f284139922916edd2dd584f576da9fb3445518
Reviewed-on: https://chromium-review.googlesource.com/c/crosvm/crosvm/+/3966928
Reviewed-by: Dennis Kempin <denniskempin@google.com>
Commit-Queue: Zihan Chen <zihanchen@google.com>
2022-10-26 23:05:08 +00:00

300 lines
9.6 KiB
Python
Executable file

#!/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 argh import arg # type: ignore
from impl.common import CROSVM_ROOT, cmd, chdir, cros_repo_root, is_cros_repo, quoted, run_main
from typing import Optional, Tuple, List
import getpass
import shutil
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_user"
CROS_IMAGE_NAME = "gcr.io/crosvm-infra-experimental/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)
DOCKER_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",
f"--env OUTSIDE_UID={os.getuid()}",
f"--env OUTSIDE_GID={os.getgid()}",
]
PODMAN_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",
]
PRIVILEGED_ARGS = [
# Share devices and syslog
"--volume /dev/log:/dev/log",
"--device /dev/net/tun",
"--device /dev/vhost-net",
"--device /dev/vhost-vsock",
# For plugin process jail
"--mount type=tmpfs,destination=/var/empty",
]
PODMAN_IS_DEFAULT = shutil.which("docker") == None
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",
]
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.
docker(
f"run --detach --name {container_name(cros)}",
*docker_args,
"sleep infinity",
).fg(quiet=True)
cid = container_id(docker, cros)
print(f"Started container ({cid}).")
else:
cid = container_id(docker, cros)
print(f"Using existing container ({cid}).")
return cid
@arg("command", nargs=argparse.REMAINDER)
def main(
command: Tuple[str, ...],
stop: bool = False,
clean: bool = False,
hermetic: bool = False,
interactive: bool = False,
podman: bool = PODMAN_IS_DEFAULT,
self_test: bool = False,
pull: bool = False,
unprivileged: bool = False,
cros: bool = False,
):
chdir(CROSVM_ROOT)
if cros and unprivileged:
print("ERROR: crOS container must be run in privileged mode")
sys.exit(1)
if unprivileged:
print("WARNING: Running dev_container with --unprivileged is a work in progress.")
print("Not all tests are expected to pass.")
print()
docker_args = [
*workspace_mount_args(cros),
*(PRIVILEGED_ARGS if not unprivileged else []),
]
if podman:
print("WARNING: Running dev_container with podman is experimental.")
print("It is strongly recommended to use docker.")
print()
docker = cmd("podman")
docker_args += [*PODMAN_ARGS]
else:
docker = cmd("docker")
docker_args += [
"--privileged" if not unprivileged else None,
*DOCKER_ARGS,
]
if cros:
docker_args.append(CROS_IMAGE_NAME)
else:
docker_args.append(DEV_IMAGE_NAME + ":" + DEV_IMAGE_VERSION)
if self_test:
TestDevContainer.docker = docker
suite = unittest.defaultTestLoader.loadTestsFromTestCase(TestDevContainer)
unittest.TextTestRunner().run(suite)
return
if stop:
if not delete_container(docker, cros):
print(f"container is not running.")
return
if clean:
delete_container(docker, cros)
if pull:
if cros:
docker("pull", "gcr.io/crosvm-infra-experimental/crosvm_cros_cloudbuild").fg()
else:
docker("pull", f"gcr.io/crosvm-infra/crosvm_dev:{DEV_IMAGE_VERSION}").fg()
docker("pull", f"gcr.io/crosvm-infra/crosvm_dev_user:{DEV_IMAGE_VERSION}").fg()
return
# If a command is provided run non-interactive unless explicitly asked for.
tty_args = []
if not command or interactive:
if not sys.stdin.isatty():
raise Exception("Trying to run an interactive session in a non-interactive terminal.")
tty_args = ["--interactive", "--tty"]
elif sys.stdin.isatty():
# Even if run non-interactively, we do want to pass along a tty for proper output.
tty_args = ["--tty"]
# Start an interactive shell by default
if hermetic:
# cmd is passed to entrypoint
quoted_cmd = list(map(quoted, command))
docker(f"run --rm", *tty_args, *docker_args, *quoted_cmd).fg()
else:
# cmd is executed directly
cid = ensure_container_is_alive(docker, docker_args, cros)
if podman:
if not command:
command = ("/bin/bash",)
else:
if not command:
command = ("/tools/entrypoint.sh",)
else:
command = ("/tools/entrypoint.sh",) + tuple(command)
quoted_cmd = list(map(quoted, command))
docker("exec", *tty_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__":
run_main(main)