diff --git a/.config/constraints.txt b/.config/constraints.txt index b3bb39b..fd3b480 100644 --- a/.config/constraints.txt +++ b/.config/constraints.txt @@ -14,9 +14,10 @@ ansible-navigator==24.7.0 ansible-runner==2.4.0 ansible-sign==0.1.1 asgiref==3.8.1 -astroid==3.2.3 +astroid==3.2.4 attrs==23.2.0 babel==2.15.0 +backports-strenum==1.3.1 beautifulsoup4==4.12.3 bindep==2.11.0 black==24.4.2 @@ -67,6 +68,7 @@ jsonschema==4.23.0 jsonschema-path==0.3.3 jsonschema-specifications==2023.12.1 lazy-object-proxy==1.10.0 +libtmux==0.37.0 linkchecker==10.4.0 lockfile==0.12.2 markdown==3.6 @@ -117,7 +119,7 @@ ptyprocess==0.7.0 pycparser==2.22 pydoclint==0.5.6 pygments==2.18.0 -pylint==3.2.5 +pylint==3.2.6 pymdown-extensions==10.8.1 pyproject-api==1.7.1 pyproject-hooks==1.1.0 @@ -133,14 +135,13 @@ pyyaml-env-tag==0.1 referencing==0.31.1 regex==2024.5.15 requests==2.32.3 -requirements-parser==0.9.0 resolvelib==1.0.1 rfc3339-validator==0.1.4 rich==13.7.1 rpds-py==0.19.0 ruamel-yaml==0.18.6 ruamel-yaml-clib==0.2.8 -ruff==0.5.2 +ruff==0.5.4 six==1.16.0 soupsieve==2.5 sqlparse==0.5.1 @@ -155,7 +156,6 @@ tox==4.16.0 tox-ansible==24.7.0 types-pyyaml==6.0.12.20240311 types-requests==2.32.0.20240712 -types-setuptools==70.3.0.20240710 typing-extensions==4.12.2 tzdata==2024.1 urllib3==2.2.2 diff --git a/.config/requirements-test.in b/.config/requirements-test.in index 85135b1..5697a47 100644 --- a/.config/requirements-test.in +++ b/.config/requirements-test.in @@ -1,6 +1,7 @@ ansible-dev-tools[server] black coverage[toml] +libtmux mypy pip-tools pre-commit diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 43a6e3e..a0e0844 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -34,7 +34,7 @@ repos: )$ - repo: https://github.com/pycontribs/mirrors-prettier - rev: v3.3.2 + rev: v3.3.3 hooks: - id: prettier always_run: true @@ -59,20 +59,20 @@ repos: - id: tox-ini-fmt - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.5.0 + rev: v0.5.4 hooks: - id: ruff args: - --exit-non-zero-on-fix - repo: https://github.com/streetsidesoftware/cspell-cli - rev: v8.9.0 + rev: v8.11.0 hooks: - id: cspell name: Spell check with cspell - repo: https://github.com/jsh9/pydoclint - rev: "0.5.3" + rev: "0.5.6" hooks: - id: pydoclint # This allows automatic reduction of the baseline file when needed. @@ -80,7 +80,7 @@ repos: pass_filenames: false - repo: https://github.com/pycqa/pylint.git - rev: v3.2.4 + rev: v3.2.6 hooks: - id: pylint args: @@ -90,12 +90,13 @@ repos: - ansible-navigator - django - gunicorn + - libtmux - openapi_core - pytest - pyyaml - repo: https://github.com/pre-commit/mirrors-mypy.git - rev: v1.10.1 + rev: v1.11.0 hooks: - id: mypy additional_dependencies: @@ -103,6 +104,7 @@ repos: - ansible-navigator - django-stubs[compatible-mypy] - jinja2 + - libtmux - openapi-core>=0.19.1 - pytest - types-pyyaml diff --git a/final/Containerfile b/final/Containerfile index 7664001..ea2ee3e 100644 --- a/final/Containerfile +++ b/final/Containerfile @@ -51,7 +51,8 @@ python3-pyyaml \ python3-ruamel-yaml \ python3-wheel \ --exclude container-selinux \ - && microdnf clean all + && microdnf clean all \ + && ln -s /usr/bin/vim /usr/bin/vi RUN useradd podman; \ echo -e "podman:1:999\npodman:1001:64535" > /etc/subuid; \ diff --git a/tests/conftest.py b/tests/conftest.py index d979733..3e56ead 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -25,6 +25,7 @@ import subprocess import sys import time +import warnings from collections.abc import Callable from dataclasses import dataclass @@ -33,8 +34,6 @@ import pytest import requests -from ansible_navigator.utils.packaged_data import ImageEntry - import ansible_dev_tools # noqa: F401 @@ -55,6 +54,7 @@ class Infrastructure: only_container: Only container tests proc: The server process server: Server required + navigator_ee: The image to use with ansible navigator """ session: pytest.Session @@ -66,6 +66,7 @@ class Infrastructure: only_container: bool = False proc: None | subprocess.Popen[bytes] = None server: bool = False + navigator_ee: str = "" def __post_init__(self) -> None: """Initialize the infrastructure. @@ -236,7 +237,7 @@ def pytest_sessionfinish(session: pytest.Session) -> None: _stop_server() -PODMAN_CMD = """{container_engine} run -d --rm +BASE_CMD = """{container_engine} run -d --rm --cap-add=SYS_ADMIN --cap-add=SYS_RESOURCE --device "/dev/fuse" @@ -247,55 +248,59 @@ def pytest_sessionfinish(session: pytest.Session) -> None: --security-opt "apparmor=unconfined" --security-opt "label=disable" --security-opt "seccomp=unconfined" - --user=root - --userns=host -v $PWD:/workdir - -v ansible-dev-tools-container-test-storage-podman:/var/lib/containers \ - {image_name} - adt server --port 8001 &""" + -v ansible-dev-tools-container-test-storage-podman:/var/lib/containers +""" -DOCKER_CMD = """{container_engine} run -d --rm - --cap-add=SYS_ADMIN - --cap-add=SYS_RESOURCE - --device "/dev/fuse" - -e NO_COLOR=1 - --hostname=ansible-dev-container - --name={container_name} - -p 8001:8001 - --security-opt "apparmor=unconfined" - --security-opt "label=disable" - --security-opt "seccomp=unconfined" - --user=podman - -v $PWD:/workdir - -v ansible-dev-tools-container-test-storage-docker:/var/lib/containers \ - {image_name} - adt server --port 8001""" +PODMAN_CMD = """ --user=root + --userns=host +""" + +DOCKER_CMD = """ --user=podman +""" + +END = """ {image_name} + adt server --port 8001 + """ def _start_container() -> None: """Start the container. The default image for navigator is pulled ahead of time. + It is determined by the container image name. If the image name + starts with localhost, the default ee for navigator is pulled. + If the image name contains a /, that is used, otherwise the default + ee for navigator is pulled. Raises: ValueError: If the container engine is not podman or docker. """ + cmd = ( + f"{INFRASTRUCTURE.container_engine} kill {INFRASTRUCTURE.container_name};" + f"{INFRASTRUCTURE.container_engine} rm {INFRASTRUCTURE.container_name}" + ) + subprocess.run(cmd, check=False, capture_output=True, shell=True, text=True) + + auth_file = "$XDG_RUNTIME_DIR/containers/auth.json" + auth_mount = "" + if "XDG_RUNTIME_DIR" in os.environ and Path(os.path.expandvars(auth_file)).exists(): + auth_mount = f" -v {auth_file}:/run/containers/0/auth.json" + if "podman" in INFRASTRUCTURE.container_engine: - cmd = PODMAN_CMD.format( - container_engine=INFRASTRUCTURE.container_engine, - container_name=INFRASTRUCTURE.container_name, - image_name=INFRASTRUCTURE.image_name, - ) + cmd = BASE_CMD + PODMAN_CMD + auth_mount + END + warnings.warn("Podman auth mount added: " + auth_mount, stacklevel=0) elif "docker" in INFRASTRUCTURE.container_engine: - cmd = DOCKER_CMD.format( - container_engine=INFRASTRUCTURE.container_engine, - container_name=INFRASTRUCTURE.container_name, - image_name=INFRASTRUCTURE.image_name, - ) + cmd = BASE_CMD + DOCKER_CMD + END else: err = f"Container engine {INFRASTRUCTURE.container_engine} not found." raise ValueError(err) - cmd = cmd.replace("\n", " ") + + cmd = cmd.replace("\n", " ").format( + container_engine=INFRASTRUCTURE.container_engine, + container_name=INFRASTRUCTURE.container_name, + image_name=INFRASTRUCTURE.image_name, + ) try: subprocess.run(cmd, check=True, capture_output=True, shell=True, text=True) except subprocess.CalledProcessError as exc: @@ -307,10 +312,48 @@ def _start_container() -> None: ) pytest.fail(err) - nav_ee = ImageEntry.DEFAULT_EE.get(app_name="ansible_navigator") + # image is local, can't be pulled, use default + if INFRASTRUCTURE.image_name.startswith("localhost"): + nav_ee = get_nav_default_ee_in_container() + warning = f"localhost in image name, pulling default {nav_ee} for navigator" + # dots and slashes in image name, use it + elif "/" in INFRASTRUCTURE.image_name and "." in INFRASTRUCTURE.image_name: + nav_ee = INFRASTRUCTURE.image_name + warning = f"/ and . in image name, pulling {nav_ee} for navigator" + # otherwise, use default + else: + nav_ee = get_nav_default_ee_in_container() + warning = f"localhost / . not in image name, pulling default {nav_ee} for navigator" + warnings.warn(warning, stacklevel=0) + INFRASTRUCTURE.navigator_ee = nav_ee _proc = _exec_container(command=f"podman pull {nav_ee}") +def get_nav_default_ee_in_container() -> str: + """Get the default ee for navigator in the container. + + Returns: + str: The default ee for navigator in the container. + """ + cmd = ( + 'python -c "from ansible_navigator.utils.packaged_data import ImageEntry;' + 'print(ImageEntry.DEFAULT_EE.get(app_name=\\"ansible_navigator\\"))"' + ) + + proc = _exec_container(cmd) + return proc.stdout.strip() + + +@pytest.fixture(name="nav_default_ee") +def nav_default_ee() -> str: + """Get the default ee for navigator in the container. + + Returns: + str: The default ee for navigator in the container. + """ + return get_nav_default_ee_in_container() + + def _stop_container() -> None: """Stop the container.""" cmd = [ diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000..c07d429 --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,104 @@ +"""Fixtures for integration tests.""" + +import time + +from collections.abc import Generator + +import pytest + +from libtmux import Pane, Session + +from ..conftest import Infrastructure # noqa: TID252 + + +@pytest.fixture() +def session_params() -> dict[str, int]: + """Set the tmux session parameters. + + Returns: + dict: The tmux session parameters. + """ + return { + "x": 132, + "y": 24, + } + + +class ContainerTmux: + """A tmux session attached to the container.""" + + def __init__(self, session: Session, infrastructure: Infrastructure) -> None: + """Initialize an instance of ContainerTmux. + + Args: + session: The tmux session. + infrastructure: The testing infrastructure. + """ + self.cmds: list[str] = [] + self.pane: Pane | None = session.active_pane + self.infrastructure: Infrastructure = infrastructure + self._shell_in_container() + + def _shell_in_container(self) -> None: + """Open a shell in the container.""" + cmd = ( + f"{self.infrastructure.container_engine} exec -it" + f" {self.infrastructure.container_name} /bin/zsh" + ) + self.send_and_wait(cmd, "workdir", 3) + + def send_and_wait(self, cmd: str, wait_for: str, timeout: float = 3.0) -> list[str]: + """Send a command and wait for a response. + + Args: + cmd: The command to send. + wait_for: The string to wait for. + timeout: The timeout. + + Returns: + The stdout from the command. + """ + self.cmds.append(cmd) + if not self.pane: + err = "No active pane found." + pytest.fail(err) + self.pane.send_keys(cmd) + timeout = time.time() + timeout + while True: + stdout = self.pane.capture_pane() + if not wait_for: + return stdout if isinstance(stdout, list) else [stdout] + if any(wait_for in line for line in stdout): + return stdout if isinstance(stdout, list) else [stdout] + if time.time() > timeout: + break + error = f"Timeout waiting for {wait_for} in {stdout}" + pytest.fail(error) + + def exit(self) -> None: + """Exit the tmux session.""" + if any("ansible-navigator" in cmd for cmd in self.cmds): + self.send_and_wait(cmd=":q", wait_for="workdir") + self.send_and_wait(cmd="exit", wait_for="") + + +@pytest.fixture() +def container_tmux( + infrastructure: Infrastructure, + session: Session, +) -> Generator[ContainerTmux, None, None]: + """Create a tmux session attached to the container. + + Args: + infrastructure: The testing infrastructure. + session: The tmux session. + + Yields: + ContainerTmux: A tmux session attached to the container. + + Returns: + None. + """ + _container_tmux = ContainerTmux(infrastructure=infrastructure, session=session) + yield _container_tmux + _container_tmux.exit() diff --git a/tests/integration/test_container.py b/tests/integration/test_container.py index b9b09af..00ec834 100644 --- a/tests/integration/test_container.py +++ b/tests/integration/test_container.py @@ -10,6 +10,7 @@ from ansible_dev_tools.version_builder import PKGS from ..conftest import Infrastructure # noqa: TID252 +from .conftest import ContainerTmux from .test_server_creator import test_collection_v1 as tst_collection_v1 from .test_server_creator import test_error as tst_error from .test_server_creator import test_playbook_v1 as tst_playbook_v1 @@ -38,11 +39,38 @@ def test_podman(exec_container: Callable[[str], subprocess.CompletedProcess[str] assert result.returncode == 0, "podman command failed" +@pytest.mark.container() +@pytest.mark.parametrize("app", ("nano", "tar", "vi")) +def test_app(exec_container: Callable[[str], subprocess.CompletedProcess[str]], app: str) -> None: + """Test the presence of an app in the container. + + Args: + exec_container: The container executor. + app: The app to test. + """ + result = exec_container(f"{app} --version") + assert result.returncode == 0, f"{app} command failed" + + +@pytest.mark.container() +def test_user_shell(exec_container: Callable[[str], subprocess.CompletedProcess[str]]) -> None: + """Test the user shell. + + Args: + exec_container: The container executor. + """ + result = exec_container("cat /etc/passwd | grep root | grep zsh") + assert result.returncode == 0, "zsh not found in /etc/passwd" + result = exec_container("cat /etc/passwd | grep podman | grep zsh") + assert result.returncode == 0, "zsh not found in /etc/passwd" + + @pytest.mark.container() def test_navigator_simple_c_in_c( exec_container: Callable[[str], subprocess.CompletedProcess[str]], test_fixture_dir_container: Path, tmp_path: Path, + infrastructure: Infrastructure, ) -> None: """Test ansible-navigator run against a simple playbook within the container. @@ -50,11 +78,13 @@ def test_navigator_simple_c_in_c( exec_container: The container executor. test_fixture_dir_container: The test fixture directory. tmp_path: The temporary directory. + infrastructure: The testing infrastructure. """ playbook = test_fixture_dir_container / "site.yml" result = exec_container( f"ansible-navigator run {playbook}" - f" --mode stdout --pae false --lf {tmp_path}/navigator.log", + f" --mode stdout --pae false --lf {tmp_path}/navigator.log" + f" --eei {infrastructure.navigator_ee} --pp never", ) assert "Success" in result.stdout assert "ok=1" in result.stdout @@ -81,9 +111,9 @@ def test_navigator_simple( playbook = test_fixture_dir / "site.yml" cmd = ( f" ansible-navigator run {playbook}" - f" --mode stdout --pp never --pae false --lf {tmp_path}/navigator.log" + f" --mode stdout --pae false --lf {tmp_path}/navigator.log" f" --ce {infrastructure.container_engine.split('/')[-1]}" - f" --eei {infrastructure.image_name}" + f" --pp never --eei {infrastructure.image_name}" ) stdout, stderr, return_code = cmd_in_tty(cmd) assert not stderr @@ -122,3 +152,98 @@ def test_playbook_v1_container(server_in_container_url: str, tmp_path: Path) -> tmp_path: The temporary directory. """ tst_playbook_v1(server_url=server_in_container_url, tmp_path=tmp_path) + + +@pytest.mark.container() +def test_nav_collections( + container_tmux: ContainerTmux, + tmp_path: Path, + infrastructure: Infrastructure, +) -> None: + """Test ansible-navigator collections. + + Args: + container_tmux: A tmux session attached to the container. + tmp_path: The temporary directory + infrastructure: The testing infrastructure + """ + cmd = ( + f"ansible-navigator collections --lf {tmp_path}/navigator.log" + f" --pp never --eei {infrastructure.navigator_ee}" + ) + stdout = container_tmux.send_and_wait(cmd=cmd, wait_for=":help help", timeout=10) + assert any("ansible.builtin" in line for line in stdout) + assert any("ansible.posix" in line for line in stdout) + cmd = ":0" + stdout = container_tmux.send_and_wait(cmd=cmd, wait_for=":help help") + assert any("add_host" in line for line in stdout) + + +@pytest.mark.container() +def test_nav_images( + container_tmux: ContainerTmux, + tmp_path: Path, + infrastructure: Infrastructure, +) -> None: + """Test ansible-navigator images. + + Args: + container_tmux: A tmux session attached to the container. + tmp_path: The temporary directory + infrastructure: The testing infrastructure + """ + cmd = ( + f"ansible-navigator images --lf {tmp_path}/nav.log" + f" --pp never --eei {infrastructure.navigator_ee}" + ) + stdout = container_tmux.send_and_wait(cmd=cmd, wait_for=":help help", timeout=10) + image = infrastructure.navigator_ee.split(":")[0].split("/")[-1] + assert any(image in line for line in stdout) + + +@pytest.mark.container() +def test_nav_playbook( + container_tmux: ContainerTmux, + tmp_path: Path, + infrastructure: Infrastructure, +) -> None: + """Test ansible-navigator run using a creator created playbook. + + Args: + container_tmux: A tmux session attached to the container. + tmp_path: The temporary directory + infrastructure: The testing infrastructure + """ + cmd = f"ansible-creator init playbook test_ns.test_name {tmp_path}" + stdout = container_tmux.send_and_wait(cmd=cmd, wait_for="created", timeout=10) + output = "Note: ansible project created" + assert any(output in line for line in stdout) + cmd = ( + f"cd {tmp_path} &&" + f" ansible-navigator run site.yml" + f" --pp never --eei {infrastructure.navigator_ee}" + ) + stdout = container_tmux.send_and_wait(cmd=cmd, wait_for="Successful", timeout=10) + assert stdout[-1].endswith("Successful") + + +@pytest.mark.container() +def test_nav_collection(container_tmux: ContainerTmux, tmp_path: Path) -> None: + """Test ansible-navigator run using a creator created collection. + + Args: + container_tmux: A tmux session attached to the container. + tmp_path: The temporary directory + """ + namespace = "test_ns" + name = "test_name" + collection_path = tmp_path / "collections" / "ansible_collections" / namespace / name + cmd = f"ansible-creator init collection test_ns.test_name {collection_path}" + stdout = container_tmux.send_and_wait(cmd=cmd, wait_for="created", timeout=10) + output = f"Note: collection {namespace}.{name} created" + assert any(output in line for line in stdout) + cmd = ( + f"ANSIBLE_COLLECTIONS_PATH={tmp_path}/collections ansible-navigator collections --ee false" + ) + stdout = container_tmux.send_and_wait(cmd=cmd, wait_for=":help help", timeout=10) + assert any(f"{namespace}.{name}" in line for line in stdout) diff --git a/tox.ini b/tox.ini index d80225f..0071568 100644 --- a/tox.ini +++ b/tox.ini @@ -26,6 +26,7 @@ pass_env = SSH_AUTH_SOCK TERM USER + XDG_RUNTIME_DIR set_env = !milestone: PIP_CONSTRAINT = {toxinidir}/.config/constraints.txt COVERAGE_COMBINED = {envdir}/.coverage