Skip to content

Commit

Permalink
VM Tests: Run Image Customizer container. (#21)
Browse files Browse the repository at this point in the history
Add executing the Image Customizer container in Docker to the basic
test.

The test suite's Makefile will call the toolkit's Makefile to
automatically rebuild the imagecustomizer binary and the container. This
avoids the user needing to provide a URL to the container. And it
ensures the tests are always running against the code within the
checked-out repo.
  • Loading branch information
cwize1 authored Dec 12, 2024
1 parent 6b4ec8f commit 97a3fb6
Show file tree
Hide file tree
Showing 9 changed files with 272 additions and 6 deletions.
21 changes: 20 additions & 1 deletion test/vmtests/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@ BLACK=${VENV_BIN_DIR}/black
ISORT=${VENV_BIN_DIR}/isort
MYPY=${VENV_BIN_DIR}/mypy

TOOLKIT_DIR=../../toolkit
TOOLS_BIN_DIR=${TOOLKIT_DIR}/out/tools
IMAGE_CUSTOMIZER_BIN=${TOOLS_BIN_DIR}/imagecustomizer

IMAGE_CUSTOMIZER_CONTAINER_TAG=imagecustomizer:dev

KEEP_ENVIRONMENT ?= n

.PHONY: create-venv
create-venv:
rm -rf "${VENV_DIR}"
Expand Down Expand Up @@ -44,9 +52,20 @@ fix-isort:
fix-black:
${BLACK} vmtests

.PHONY: ${IMAGE_CUSTOMIZER_BIN}
${IMAGE_CUSTOMIZER_BIN}:
${MAKE} -C ${TOOLKIT_DIR} go-imagecustomizer REBUILD_TOOLS=y

.PHONY: image-customizer-container
image-customizer-container: ${IMAGE_CUSTOMIZER_BIN}
${TOOLKIT_DIR}/tools/imagecustomizer/container/build-mic-container.sh -t ${IMAGE_CUSTOMIZER_CONTAINER_TAG}

.PHONY: run
run:
run: image-customizer-container
${PYTEST} \
--image-customizer-container-url="${IMAGE_CUSTOMIZER_CONTAINER_TAG}" \
--core-efi-azl2="${CORE_EFI_AZL2}" \
$(if $(filter y,$(KEEP_ENVIRONMENT)),--keep-environment) \
--log-cli-level=DEBUG \
--show-capture=all \
--tb=short \
Expand Down
29 changes: 27 additions & 2 deletions test/vmtests/README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
# VM Tests

A test suite that runs the containerized version of the image customizer tool.

## How to run

Requirements:

- Python3
- Docker

Steps:

Expand All @@ -14,12 +17,34 @@ Steps:
make create-venv
```

2. Run:
2. Download a copy of the Azure Linux 2.0 core-efi VHDX image file.

3. Run:

```bash
make run
CORE_EFI_AZL2="<core-efi-vhdx>"
make run CORE_EFI_AZL2="$CORE_EFI_AZL2"
```

Where:

- `<core-efi-vhdx>` is the path of the VHDX file downloaded in Step 2.

Note: By default, the `${HOME}/.ssh/id_ed25519` SSH private key is used. If you want
to use a different private key, then set the `SSH_PRIVATE_KEY_FILE` variable when
calling `make`.

## Debugging

If you want to keep the resources that the test creates around after the test has
finished running, then add:

```bash
KEEP_ENVIRONMENT=y
```

to the `make` call.

## Linting, mypy, and other code checks

This project uses Black for automatic code formatting, isort for sorting imports, mypy
Expand Down
3 changes: 2 additions & 1 deletion test/vmtests/requirements.txt
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
pytest == 8.3.3
docker == 7.1.0
pytest == 8.3.3
1 change: 1 addition & 0 deletions test/vmtests/requirements/dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ black == 24.8.0
flake8 == 7.1.0
isort == 5.13.2
mypy == 1.13.0
types-docker == 7.1.0.20240827
93 changes: 93 additions & 0 deletions test/vmtests/vmtests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.

import os
import random
import shutil
import string
import tempfile
from pathlib import Path
from typing import Generator

import docker
import pytest
from docker import DockerClient

SCRIPT_PATH = Path(__file__).parent
TEST_CONFIGS_DIR = SCRIPT_PATH.joinpath("../../../toolkit/tools/pkg/imagecustomizerlib/testdata")


def pytest_addoption(parser: pytest.Parser) -> None:
parser.addoption("--keep-environment", action="store_true", help="Keep the resources created during the test")
parser.addoption("--core-efi-azl2", action="store", help="Path to Azure Linux 2.0 core-efi qcow2 image")
parser.addoption("--image-customizer-container-url", action="store", help="Image Customizer container image URL")


@pytest.fixture(scope="session")
def keep_environment(request: pytest.FixtureRequest) -> Generator[bool, None, None]:
flag = request.config.getoption("--keep-environment")
assert isinstance(flag, bool)
yield flag


@pytest.fixture(scope="session")
def session_temp_dir(request: pytest.FixtureRequest, keep_environment: bool) -> Generator[Path, None, None]:
build_dir = SCRIPT_PATH.joinpath("build")
os.makedirs(build_dir, exist_ok=True)

temp_path = tempfile.mkdtemp(prefix="vmtests-", dir=build_dir)

# Ensure VM can access directory.
os.chmod(temp_path, 0o775)

yield Path(temp_path)

if not keep_environment:
shutil.rmtree(temp_path)


@pytest.fixture(scope="function")
def test_instance_name(request: pytest.FixtureRequest) -> Generator[str, None, None]:
instance_suffix = "".join(random.choice(string.ascii_uppercase) for _ in range(5))
yield request.node.name + "-" + instance_suffix


# pytest has an in-built fixture called tmp_path. But that uses /tmp, which sits in memory.
# That can be problematic when dealing with image files, which can be quite large.
@pytest.fixture(scope="function")
def test_temp_dir(
request: pytest.FixtureRequest, session_temp_dir: Path, test_instance_name: str, keep_environment: bool
) -> Generator[Path, None, None]:
temp_path = session_temp_dir.joinpath(test_instance_name)

# Ensure VM can access directory.
temp_path.mkdir(0o775)

yield Path(temp_path)

if not keep_environment:
shutil.rmtree(temp_path)


@pytest.fixture(scope="session")
def core_efi_azl2(request: pytest.FixtureRequest) -> Generator[Path, None, None]:
image = request.config.getoption("--core-efi-azl2")
if not image:
raise Exception("--core-efi-azl2 is required for test")
yield Path(image)


@pytest.fixture(scope="session")
def image_customizer_container_url(request: pytest.FixtureRequest) -> Generator[str, None, None]:
url = request.config.getoption("--image-customizer-container-url")
if not url:
raise Exception("--image-customizer-container-url is required for test")
yield url


@pytest.fixture(scope="session")
def docker_client() -> Generator[DockerClient, None, None]:
client = docker.from_env()
yield client

client.close() # type: ignore
26 changes: 24 additions & 2 deletions test/vmtests/vmtests/test_no_change.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,28 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.

from pathlib import Path

def test_no_change() -> None:
pass
from docker import DockerClient

from .conftest import TEST_CONFIGS_DIR
from .utils.imagecustomizer import run_image_customizer


def test_no_change(
docker_client: DockerClient,
image_customizer_container_url: str,
core_efi_azl2: Path,
test_temp_dir: Path,
) -> None:
config_path = TEST_CONFIGS_DIR.joinpath("nochange-config.yaml")
output_image_path = test_temp_dir.joinpath("image.qcow2")

run_image_customizer(
docker_client,
image_customizer_container_url,
core_efi_azl2,
config_path,
"qcow2",
output_image_path,
)
2 changes: 2 additions & 0 deletions test/vmtests/vmtests/utils/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
47 changes: 47 additions & 0 deletions test/vmtests/vmtests/utils/docker_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.

import logging
from typing import Any

import docker
from docker import DockerClient
from docker.models.containers import Container


# Can be used with a `with` statement for deleting a Docker container.
class ContainerRemove:
def __init__(self, container: Container):
self.container: Container = container

def close(self) -> None:
self.container.remove(force=True)

def __enter__(self) -> "ContainerRemove":
return self

def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None:
self.close()


# Run a container, log its stdout and stderr, and wait for it to complete.
def container_run(docker_client: DockerClient, *args: Any, **kwargs: Any) -> "docker._types.WaitContainerResponse":
with ContainerRemove(docker_client.containers.run(*args, **kwargs)) as container:
return container_log_and_wait(container.container)


# Waits for a docker container to exit, while logging stdout and stderr.
def container_log_and_wait(container: Container) -> "docker._types.WaitContainerResponse":
# Log stdout and stderr.
logs = container.logs(stdout=True, stderr=True, stream=True)
for log in logs:
logging.debug(log.decode("utf-8").strip())

# Wait for the container to close.
result = container.wait()

exit_code = result["StatusCode"]
if exit_code != 0:
raise Exception(f"Container failed with {exit_code}")

return result
56 changes: 56 additions & 0 deletions test/vmtests/vmtests/utils/imagecustomizer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.

from pathlib import Path

from docker import DockerClient

from .docker_utils import container_run


# Run the containerized version of the imagecustomizer tool.
def run_image_customizer(
docker_client: DockerClient,
image_customizer_container_url: str,
base_image_path: Path,
config_path: Path,
output_image_format: str,
output_image_path: Path,
) -> None:
container_base_image_dir = Path("/mic/base_image")
container_config_dir = Path("/mic/config")
container_output_image_dir = Path("/mic/output_image")
container_build_dir = Path("/mic/build")

base_image_dir = base_image_path.parent.absolute()
config_dir = config_path.parent.absolute()
output_image_dir = output_image_path.parent.absolute()

container_base_image_path = container_base_image_dir.joinpath(base_image_path.name)
container_config_path = container_config_dir.joinpath(config_path.name)
container_output_image_path = container_output_image_dir.joinpath(output_image_path.name)

args = [
"imagecustomizer",
"--image-file",
str(container_base_image_path),
"--config-file",
str(container_config_path),
"--build-dir",
str(container_build_dir),
"--output-image-format",
output_image_format,
"--output-image-file",
str(container_output_image_path),
"--log-level",
"debug",
]

volumes = [
f"{base_image_dir}:{container_base_image_dir}:z",
f"{config_dir}:{container_config_dir}:z",
f"{output_image_dir}:{container_output_image_dir}:z",
"/dev:/dev",
]

container_run(docker_client, image_customizer_container_url, args, detach=True, privileged=True, volumes=volumes)

0 comments on commit 97a3fb6

Please sign in to comment.