From 23419751c3e96943b4fe4cda420cf2b2564b5b75 Mon Sep 17 00:00:00 2001 From: Lars Kellogg-Stedman Date: Wed, 26 Jun 2024 23:56:00 -0400 Subject: [PATCH 1/3] Add integration tests for container image Verify that we can successfully build an esi-leap container image and that we can successfully start a container from the image. These tests are run automatically by .github/workflows/build-image.yaml, which on pushes to the `master` branch will also push the image into the ghcr.io container repository. The integration tests introduced in this commit are minimal in nature and are ripe for further work if someone is feeling inspired. To run the integration tests manually: tox -e integration --- .containerignore | 3 + .github/workflows/build-image.yaml | 83 ++++++ esi_leap/integration_tests/__init__.py | 0 .../integration_tests/test_container_image.py | 247 ++++++++++++++++++ test-requirements.txt | 2 + tox.ini | 3 + 6 files changed, 338 insertions(+) create mode 100644 .containerignore create mode 100644 .github/workflows/build-image.yaml create mode 100644 esi_leap/integration_tests/__init__.py create mode 100644 esi_leap/integration_tests/test_container_image.py diff --git a/.containerignore b/.containerignore new file mode 100644 index 00000000..c0785f88 --- /dev/null +++ b/.containerignore @@ -0,0 +1,3 @@ +.tox +.coverage +.pytest_cache diff --git a/.github/workflows/build-image.yaml b/.github/workflows/build-image.yaml new file mode 100644 index 00000000..8ee5b245 --- /dev/null +++ b/.github/workflows/build-image.yaml @@ -0,0 +1,83 @@ +name: Build container image + +on: + push: + pull_request: + workflow_dispatch: + +env: + # Use docker.io for Docker Hub if empty + REGISTRY: ghcr.io + # github.repository as / + IMAGE_NAME: ${{ github.repository }} + +jobs: + build: + + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + sudo apt -y install podman + pip install tox + + - name: Build image for testing + uses: docker/build-push-action@v6 + with: + context: . + file: Containerfile + tags: esi-leap-testing-${{ github.run_id }} + + - name: Run integration tests + env: + ESI_LEAP_IMAGE: esi-leap-testing-${{ github.run_id }} + run: | + tox -e integration + + # Login against a Docker registry except on PR + # https://github.com/docker/login-action + - name: Log into registry ${{ env.REGISTRY }} + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Extract metadata (tags, labels) for Docker + # https://github.com/docker/metadata-action + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=semver,pattern=v{{version}} + type=semver,pattern=v{{major}}.{{minor}} + type=semver,pattern=v{{major}} + type=ref,event=branch + type=ref,event=pr + type=sha + + # Build and push Docker image with Buildx (don't push on PR) + # https://github.com/docker/build-push-action + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + push: ${{ github.event_name != 'pull_request' && github.ref_name == 'master' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + file: Containerfile diff --git a/esi_leap/integration_tests/__init__.py b/esi_leap/integration_tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/esi_leap/integration_tests/test_container_image.py b/esi_leap/integration_tests/test_container_image.py new file mode 100644 index 00000000..fe2b61d3 --- /dev/null +++ b/esi_leap/integration_tests/test_container_image.py @@ -0,0 +1,247 @@ +"""These tests bring up an esi-container and then run tests against the esi-leap API. + +Note that the fixtures in this file are session-scoped (rather than +the default method-scoped) in avoid the cost of repeatedly +creating/deleting the container environment. This means that any tests +in this file must be avoid side effects that would impact subsequent +tests. +""" + +import os +import subprocess +import requests +import pytest +import string +import random +import tempfile +import time +import docker + +from pathlib import Path + +esi_leap_config_template = """ +[DEFAULT] + +log_dir= +log_file= +transport_url=fake:// + +[database] +connection=mysql+pymysql://esi_leap:{mysql_user_password}@{mysql_container}/esi_leap + +[oslo_messaging_notifications] +driver=messagingv2 +transport_url=fake:// + +[oslo_concurrency] +lock_path={tmp_path}/locks + +[dummy_node] +dummy_node_dir={tmp_path}/nodes + +[pecan] +auth_enable=false +""" + + +@pytest.fixture(scope="session") +def docker_client(): + """A client for interacting with the Docker API""" + client = docker.from_env() + return client + + +@pytest.fixture(scope="session") +def tmp_path(): + """A session-scoped temporary directory that will be removed when the + session closes.""" + + with tempfile.TemporaryDirectory(prefix="pytest") as tmpdir: + yield Path(tmpdir) + + +@pytest.fixture(scope="session") +def random_string(): + """A session-scoped random string that we use to generate names, + credentials, etc. that are unique to the test session.""" + + return "".join(random.sample(string.ascii_lowercase, 8)) + + +@pytest.fixture(scope="session") +def test_network(docker_client, random_string): + """Create a Docker network for the test (and clean it up when we're done)""" + + network_name = f"esi-leap-{random_string}" + network = docker_client.networks.create(network_name) + yield network_name + network.remove() + + +@pytest.fixture(scope="session") +def mysql_user_password(random_string): + """A random password for authenticating to the mysql service""" + return f"user-{random_string}" + + +@pytest.fixture(scope="session") +def esi_leap_port(): + """The esi-leap service will be published on this host port.""" + return random.randint(10000, 30000) + + +@pytest.fixture(scope="session") +def mysql_container(docker_client, test_network, mysql_user_password, random_string): + """Run a mysql container and wait until it is healthy. The fixture value + is the container name.""" + + container_name = f"mysql-{random_string}" + root_password = f"root-{random_string}" + env = { + "MYSQL_ROOT_PASSWORD": root_password, + "MYSQL_DATABASE": "esi_leap", + "MYSQL_USER": "esi_leap", + "MYSQL_PASSWORD": mysql_user_password, + } + + # We use the healthcheck so that we can wait until mysql is ready + # before bringing up the esi-leap container. + healthcheck = { + "test": [ + "CMD", + "mysqladmin", + "ping", + f"-p{root_password}", + ], + "start_period": int(30e9), + "interval": int(5e9), + } + + container = docker_client.containers.run( + "docker.io/mysql:8", + detach=True, + network=test_network, + name=container_name, + environment=env, + healthcheck=healthcheck, + init=True, + labels={"pytest": None, "esi-leap-test": random_string}, + ) + + for _ in range(30): + container.reload() + + if container.health == "healthy": + break + + time.sleep(1) + else: + raise OSError("failed to start mysql container") + + yield container_name + + container.remove(force=True) + + +@pytest.fixture(scope="session") +def esi_leap_image(random_string): + """This will either build a new esi-leap image and return the name, or, if the + ESI_LEAP_IMAGE environment variable is set, simply return the value of that + variable.""" + + # Note that the := operator requires python >= 3.8 + if image_name := os.getenv("ESI_LEAP_IMAGE"): + return image_name + + image_name = f"esi-leap-{random_string}" + subprocess.run( + ["docker", "build", "-t", image_name, "-f", "Containerfile", "."], check=True + ) + return image_name + + +@pytest.fixture(scope="session") +def esi_leap_container( + docker_client, + test_network, + mysql_container, + mysql_user_password, + tmp_path, + random_string, + esi_leap_port, + esi_leap_image, +): + """Run the esi-leap container. Create an esi-leap configuration file from + the template and mount it at /etc/esi-leap/esi-leap.conf in the + container. + + The service is exposed on esi_leap_port so that we can access it from our + tests.""" + + container_name = f"esi-leap-api-{random_string}" + config_file = tmp_path / "esi-leap.conf" + with config_file.open("w") as fd: + fd.write( + esi_leap_config_template.format( + **{ + "tmp_path": tmp_path, + "mysql_container": mysql_container, + "mysql_user_password": mysql_user_password, + } + ) + ) + + (tmp_path / "nodes").mkdir() + (tmp_path / "locks").mkdir() + + container = docker_client.containers.run( + esi_leap_image, + detach=True, + network=test_network, + name=container_name, + init=True, + labels={"pytest": None, "esi-leap-test": random_string}, + ports={"7777/tcp": esi_leap_port}, + volumes=[f"{tmp_path}/esi-leap.conf:/etc/esi-leap/esi-leap.conf"], + ) + + for _ in range(30): + try: + res = requests.get(f"http://localhost:{esi_leap_port}/v1/offers") + if res.status_code == 200: + break + except requests.RequestException: + pass + + time.sleep(1) + else: + raise OSError("failed to start esi-leap container") + + yield container_name + + container.remove(force=True) + + +def test_api_list_offers(esi_leap_container, esi_leap_port): + res = requests.get(f"http://localhost:{esi_leap_port}/v1/offers") + assert res.status_code == 200 + assert res.json() == {"offers": []} + + +def test_api_list_leases(esi_leap_container, esi_leap_port): + res = requests.get(f"http://localhost:{esi_leap_port}/v1/leases") + assert res.status_code == 200 + assert res.json() == {"leases": []} + + +def test_api_list_events(esi_leap_container, esi_leap_port): + res = requests.get(f"http://localhost:{esi_leap_port}/v1/events") + assert res.status_code == 200 + assert res.json() == {"events": []} + + +@pytest.mark.xfail(reason="nodes endpoint requires keystone") +def test_api_list_nodes(esi_leap_container, esi_leap_port): + res = requests.get(f"http://localhost:{esi_leap_port}/v1/nodes") + assert res.status_code == 200 + assert res.json() == {"nodes": []} diff --git a/test-requirements.txt b/test-requirements.txt index be2a14f4..7c804cf3 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -21,3 +21,5 @@ WebTest>=2.0.27 # MIT bashate>=0.5.1 # Apache-2.0 flake8-import-order>=0.13 # LGPLv3 Pygments>=2.2.0 # BSD +docker>=7.1.0 +requests>=2.32.0 diff --git a/tox.ini b/tox.ini index 0c3ed2f8..ba56e8cb 100644 --- a/tox.ini +++ b/tox.ini @@ -18,6 +18,9 @@ deps = -r{toxinidir}/test-requirements.txt commands = pytest -v --cov=esi_leap {posargs} +[testenv:integration] +commands = pytest -v esi_leap/integration_tests + [testenv:pep8] commands = flake8 esi_leap {posargs} From 06120dd6a64f27ded532e7d910608e4ee9867725 Mon Sep 17 00:00:00 2001 From: Lars Kellogg-Stedman Date: Fri, 12 Jul 2024 22:57:26 -0400 Subject: [PATCH 2/3] Use current version of github actions --- .github/workflows/unit-tests.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/unit-tests.yaml b/.github/workflows/unit-tests.yaml index 6011a78a..34ed560e 100644 --- a/.github/workflows/unit-tests.yaml +++ b/.github/workflows/unit-tests.yaml @@ -5,15 +5,15 @@ on: [push, pull_request] jobs: run-unit-tests: - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest strategy: matrix: python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies From b20d056ce4137d87f1ba5cce76dee616a1b8726b Mon Sep 17 00:00:00 2001 From: Lars Kellogg-Stedman Date: Fri, 12 Jul 2024 22:54:39 -0400 Subject: [PATCH 3/3] Request dependabot to keep actions up-to-date --- .github/dependabot.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..dfd0e308 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +# Set update schedule for GitHub Actions + +version: 2 +updates: + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + # Check for updates to GitHub Actions every week + interval: "weekly"