From a3d65e1fae98138f3a9dd331996bbebc43fdae6c Mon Sep 17 00:00:00 2001 From: Zack Cerza Date: Tue, 26 May 2026 14:43:10 -0600 Subject: [PATCH 1/8] Fix linter errors Signed-off-by: Zack Cerza --- ceph_devstack/resources/ceph/__init__.py | 6 ++---- ceph_devstack/resources/ceph/utils.py | 1 + ceph_devstack/resources/test/test_testnode.py | 6 +++--- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/ceph_devstack/resources/ceph/__init__.py b/ceph_devstack/resources/ceph/__init__.py index 289197f1..acf18fd0 100644 --- a/ceph_devstack/resources/ceph/__init__.py +++ b/ceph_devstack/resources/ceph/__init__.py @@ -229,9 +229,7 @@ async def wait(self, container_name: str): logger.error(f"Could not find container {container_name}") return 1 - async def logs( - self, run_name: str = None, job_id: str = None, locate: bool = False - ): + async def logs(self, run_name: str = "", job_id: str = "", locate: bool = False): try: log_file = self.get_log_file(run_name, job_id) except FileNotFoundError: @@ -250,7 +248,7 @@ async def logs( while chunk := f.read(buffer_size): print(chunk, end="") - def get_log_file(self, run_name: str = None, job_id: str = None): + def get_log_file(self, run_name: str = "", job_id: str = ""): archive_dir = Teuthology().archive_dir.expanduser() if not run_name: diff --git a/ceph_devstack/resources/ceph/utils.py b/ceph_devstack/resources/ceph/utils.py index 6f96f5aa..43834382 100644 --- a/ceph_devstack/resources/ceph/utils.py +++ b/ceph_devstack/resources/ceph/utils.py @@ -10,6 +10,7 @@ def get_logtimestamp(dirname: str) -> datetime: match_ = RUN_DIRNAME_PATTERN.search(dirname) + assert match_ return datetime.strptime(match_.group("timestamp"), "%Y-%m-%d_%H:%M:%S") diff --git a/ceph_devstack/resources/test/test_testnode.py b/ceph_devstack/resources/test/test_testnode.py index 62fe9d30..c16372af 100644 --- a/ceph_devstack/resources/test/test_testnode.py +++ b/ceph_devstack/resources/test/test_testnode.py @@ -2,14 +2,14 @@ import pytest -from ceph_devstack.resources.ceph import TestNode +from ceph_devstack.resources.ceph import TestNode as _TestNode from ceph_devstack import config class TestTestnode: @pytest.fixture(scope="class") - def cls(self) -> type[TestNode]: - return TestNode + def cls(self) -> type[_TestNode]: + return _TestNode def test_testnode_loop_device_count_default_to_one(self, cls): testnode = cls("testnode_1") From 75da23e0e32fc6dc00d0997e1f9688a5f3e4f757 Mon Sep 17 00:00:00 2001 From: Zack Cerza Date: Wed, 13 May 2026 12:20:53 -0600 Subject: [PATCH 2/8] Drop setup.cfg Signed-off-by: Zack Cerza --- pyproject.toml | 46 ++++++++++++++++++++++++++++++++++++++-------- setup.cfg | 41 ----------------------------------------- 2 files changed, 38 insertions(+), 49 deletions(-) delete mode 100644 setup.cfg diff --git a/pyproject.toml b/pyproject.toml index 3a9f37a6..d6dfc044 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,40 @@ -[build-system] -build-backend = "setuptools.build_meta" -requires = [ - "setuptools>=45", - "wheel", - "setuptools_scm>=6.2", +[project] +name = "ceph-devstack" +authors = [{ name = "Zack Cerza", email = "zack@cerza.org" }] +license = "MIT" +license-files = ["LICENSE"] +classifiers = [ + "Intended Audience :: Developers", + "Natural Language :: English", + "Operating System :: POSIX :: Linux", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", ] +keywords = ["podman", "ceph"] +description = "Run a full teuthology lab on your laptop!" +requires-python = ">=3.8" +dependencies = ["packaging", "pre-commit", "PyYAML", "tomlkit"] +dynamic = ["version"] + +[project.readme] +file = "README.md" +content-type = "text/x-markdown" + +[project.urls] +Homepage = "https://github.com/zmc/ceph-devstack" + +[project.optional-dependencies] +test = ["pytest", "pytest-asyncio", "tox", "tox-uv"] -[tool.setuptools_scm] -version_scheme = "python-simplified-semver" +[project.scripts] +ceph-devstack = "ceph_devstack.cli:main" + +[tool.setuptools] +include-package-data = true + +[tool.setuptools.packages.find] +namespaces = false + +[build-system] +requires = ["setuptools>=61.2"] +build-backend = "setuptools.build_meta" diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 71dded2e..00000000 --- a/setup.cfg +++ /dev/null @@ -1,41 +0,0 @@ -[metadata] -name = ceph-devstack -long_description = file: README.md -long_description_content_type = text/x-markdown -url = https://github.com/zmc/ceph-devstack -author = Zack Cerza -license = MIT -license_file = LICENSE -classifiers = - Intended Audience :: Developers - License :: OSI Approved :: MIT License - Natural Language :: English - Operating System :: POSIX :: Linux - Programming Language :: Python :: 3 - Programming Language :: Python :: 3 :: Only - Programming Language :: Python :: 3.8 - Programming Language :: Python :: 3.9 - Programming Language :: Python :: 3.10 -description_content_type = text/x-rst; charset=UTF-8 -description_file = README.rst -keywords = podman, ceph -summary = Run a full teuthology lab on your laptop! - -[options] -packages = find: -install_requires = - packaging - pre-commit - PyYAML - tomlkit -python_requires = >=3.8 -include_package_data = True - -[options.entry_points] -console_scripts = - ceph-devstack = ceph_devstack.cli:main - -[options.extras_require] -test = - pytest - pytest-asyncio From f394ca290050cbacda2fad279505c95304a3e8e2 Mon Sep 17 00:00:00 2001 From: Zack Cerza Date: Tue, 26 May 2026 14:43:29 -0600 Subject: [PATCH 3/8] Move existing tests So that they can be more easily executed against modified source trees Signed-off-by: Zack Cerza --- tests/__init__.py | 0 tests/resources/__init__.py | 0 tests/resources/ceph/__init__.py | 0 tests/resources/ceph/fixtures/__init__.py | 0 .../resources/ceph}/fixtures/testnode-config.toml | 0 .../resources/test => tests/resources/ceph}/test_devstack.py | 0 .../resources/test => tests/resources/ceph}/test_testnode.py | 0 .../resources/test => tests/resources}/test_container.py | 2 +- .../resources/test => tests/resources}/test_podmanresource.py | 4 ---- tox.ini | 2 +- 10 files changed, 2 insertions(+), 6 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/resources/__init__.py create mode 100644 tests/resources/ceph/__init__.py create mode 100644 tests/resources/ceph/fixtures/__init__.py rename {ceph_devstack/resources/test => tests/resources/ceph}/fixtures/testnode-config.toml (100%) rename {ceph_devstack/resources/test => tests/resources/ceph}/test_devstack.py (100%) rename {ceph_devstack/resources/test => tests/resources/ceph}/test_testnode.py (100%) rename {ceph_devstack/resources/test => tests/resources}/test_container.py (97%) rename {ceph_devstack/resources/test => tests/resources}/test_podmanresource.py (85%) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/resources/__init__.py b/tests/resources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/resources/ceph/__init__.py b/tests/resources/ceph/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/resources/ceph/fixtures/__init__.py b/tests/resources/ceph/fixtures/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ceph_devstack/resources/test/fixtures/testnode-config.toml b/tests/resources/ceph/fixtures/testnode-config.toml similarity index 100% rename from ceph_devstack/resources/test/fixtures/testnode-config.toml rename to tests/resources/ceph/fixtures/testnode-config.toml diff --git a/ceph_devstack/resources/test/test_devstack.py b/tests/resources/ceph/test_devstack.py similarity index 100% rename from ceph_devstack/resources/test/test_devstack.py rename to tests/resources/ceph/test_devstack.py diff --git a/ceph_devstack/resources/test/test_testnode.py b/tests/resources/ceph/test_testnode.py similarity index 100% rename from ceph_devstack/resources/test/test_testnode.py rename to tests/resources/ceph/test_testnode.py diff --git a/ceph_devstack/resources/test/test_container.py b/tests/resources/test_container.py similarity index 97% rename from ceph_devstack/resources/test/test_container.py rename to tests/resources/test_container.py index 695535cf..8a274c3a 100644 --- a/ceph_devstack/resources/test/test_container.py +++ b/tests/resources/test_container.py @@ -5,7 +5,7 @@ from ceph_devstack import config from ceph_devstack.resources.container import Container -from ceph_devstack.resources.test.test_podmanresource import ( +from .test_podmanresource import ( TestPodmanResource as _TestPodmanResource, ) diff --git a/ceph_devstack/resources/test/test_podmanresource.py b/tests/resources/test_podmanresource.py similarity index 85% rename from ceph_devstack/resources/test/test_podmanresource.py rename to tests/resources/test_podmanresource.py index c46c8ba0..21a0908a 100644 --- a/ceph_devstack/resources/test/test_podmanresource.py +++ b/tests/resources/test_podmanresource.py @@ -44,10 +44,6 @@ async def test_apply(self, cls, action): async def test_cmd(self, cls): with patch("ceph_devstack.host.host.arun") as m_arun: - # at_eof() is not async, so the below lines avoid this warning: - # RuntimeWarning: coroutine 'AsyncMockMixin._execute_mock_call' was never awaited - # m_arun.return_value.stderr.at_eof = Mock() - # m_arun.return_value.stdout.at_eof = Mock() obj = cls() await obj.cmd(["0"]) print(m_arun.await_args_list) diff --git a/tox.ini b/tox.ini index d26e1c4d..7a772bcd 100644 --- a/tox.ini +++ b/tox.ini @@ -6,4 +6,4 @@ isolated_build = True deps = pytest pytest-asyncio -commands = pytest {posargs:ceph_devstack} +commands = pytest {posargs:tests} From d0a3112f1cc7a554a385af7d915f801c20c7cbeb Mon Sep 17 00:00:00 2001 From: Zack Cerza Date: Tue, 26 May 2026 14:56:36 -0600 Subject: [PATCH 4/8] tests: Reset config between tests Signed-off-by: Zack Cerza --- tests/conftest.py | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 tests/conftest.py diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..d990a375 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,9 @@ +import pytest + +from ceph_devstack import config + + +@pytest.fixture(autouse=True) +def reset_config(): + config.load() + yield From 96471ebf62c7bd403c05949a86e7c56be57eb958 Mon Sep 17 00:00:00 2001 From: Zack Cerza Date: Tue, 26 May 2026 14:54:40 -0600 Subject: [PATCH 5/8] Fix testnode test This test bug was discovered after making the config reset between tests. Signed-off-by: Zack Cerza --- tests/resources/ceph/test_testnode.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/resources/ceph/test_testnode.py b/tests/resources/ceph/test_testnode.py index c16372af..345e0f1c 100644 --- a/tests/resources/ceph/test_testnode.py +++ b/tests/resources/ceph/test_testnode.py @@ -25,6 +25,7 @@ def test_testnode_create_cmd_includes_related_devices(self, cls): assert "--device=/dev/loop7" in create_cmd def test_testnode_devices_is_based_on_loop_device_count_config(self, cls): + config.load(Path(__file__).parent.joinpath("fixtures", "testnode-config.toml")) testnode = cls("testnode_1") assert testnode.loop_device_count == 4 assert testnode.devices == [ From 1b12a97bf0bf5f45a04916696b0c29e0ad68c5ac Mon Sep 17 00:00:00 2001 From: Zack Cerza Date: Tue, 26 May 2026 20:17:27 -0600 Subject: [PATCH 6/8] Bump Python version to 3.10 Signed-off-by: Zack Cerza --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index d6dfc044..6b8b1e3a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ classifiers = [ ] keywords = ["podman", "ceph"] description = "Run a full teuthology lab on your laptop!" -requires-python = ">=3.8" +requires-python = ">=3.10" dependencies = ["packaging", "pre-commit", "PyYAML", "tomlkit"] dynamic = ["version"] From 23e4070661753546d067299e4ae055e39f409e07 Mon Sep 17 00:00:00 2001 From: Zack Cerza Date: Tue, 26 May 2026 14:49:14 -0600 Subject: [PATCH 7/8] Add new tests Signed-off-by: Zack Cerza --- .../resources/ceph/test_cephdevstack_core.py | 459 ++++++++++++++++ tests/resources/ceph/test_env_vars.py | 110 ++++ .../resources/ceph/test_requirements_ceph.py | 262 +++++++++ tests/resources/ceph/test_ssh_keypair.py | 109 ++++ tests/resources/test_container.py | 180 ++++++- tests/resources/test_misc.py | 46 ++ tests/resources/test_podmanresource.py | 4 + tests/test_config.py | 120 +++++ tests/test_deep_merge.py | 71 +++ tests/test_parse_args.py | 228 ++++++++ tests/test_requirements_core.py | 495 ++++++++++++++++++ 11 files changed, 2073 insertions(+), 11 deletions(-) create mode 100644 tests/resources/ceph/test_cephdevstack_core.py create mode 100644 tests/resources/ceph/test_env_vars.py create mode 100644 tests/resources/ceph/test_requirements_ceph.py create mode 100644 tests/resources/ceph/test_ssh_keypair.py create mode 100644 tests/resources/test_misc.py create mode 100644 tests/test_config.py create mode 100644 tests/test_deep_merge.py create mode 100644 tests/test_parse_args.py create mode 100644 tests/test_requirements_core.py diff --git a/tests/resources/ceph/test_cephdevstack_core.py b/tests/resources/ceph/test_cephdevstack_core.py new file mode 100644 index 00000000..d3b38ed2 --- /dev/null +++ b/tests/resources/ceph/test_cephdevstack_core.py @@ -0,0 +1,459 @@ +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from ceph_devstack import config +from ceph_devstack.resources.ceph import CephDevStack +from ceph_devstack.resources.ceph.containers import ( + Archive, + Beanstalk, + Paddles, + Postgres, + Pulpito, + TestNode as _TestNode, + Teuthology, +) +from ceph_devstack.resources.ceph.exceptions import TooManyJobsFound + + +class TestCephDevStackServiceSpecs: + def test_service_specs_includes_all_services(self): + devstack = CephDevStack() + assert "postgres" in devstack.service_specs + assert "paddles" in devstack.service_specs + assert "beanstalk" in devstack.service_specs + assert "pulpito" in devstack.service_specs + assert "testnode" in devstack.service_specs + assert "teuthology" in devstack.service_specs + assert "archive" in devstack.service_specs + + def test_service_specs_single_count_creates_single_object(self): + config["containers"]["postgres"]["count"] = 1 + devstack = CephDevStack() + assert len(devstack.service_specs["postgres"]["objects"]) == 1 + + def test_service_specs_multiple_count_creates_multiple_objects(self): + assert config["containers"]["testnode"]["count"] == 3 + devstack = CephDevStack() + assert len(devstack.service_specs["testnode"]["objects"]) == 3 + + def test_service_specs_zero_count_excludes_service(self): + config["containers"]["beanstalk"]["count"] = 0 + devstack = CephDevStack() + assert "beanstalk" not in devstack.service_specs + + def test_service_specs_objects_are_correct_types(self): + devstack = CephDevStack() + assert isinstance(devstack.service_specs["postgres"]["objects"][0], Postgres) + assert isinstance(devstack.service_specs["paddles"]["objects"][0], Paddles) + assert isinstance(devstack.service_specs["beanstalk"]["objects"][0], Beanstalk) + assert isinstance(devstack.service_specs["pulpito"]["objects"][0], Pulpito) + assert isinstance(devstack.service_specs["testnode"]["objects"][0], _TestNode) + assert isinstance( + devstack.service_specs["teuthology"]["objects"][0], Teuthology + ) + assert isinstance(devstack.service_specs["archive"]["objects"][0], Archive) + + def test_service_specs_named_objects_when_count_greater_than_one(self): + devstack = CephDevStack() + testnode_objects = devstack.service_specs["testnode"]["objects"] + assert testnode_objects[0].name == "testnode_0" + assert testnode_objects[1].name == "testnode_1" + assert testnode_objects[2].name == "testnode_2" + + def test_service_specs_sets_postgres_paddles_url(self): + devstack = CephDevStack() + paddles_obj = devstack.service_specs["paddles"]["objects"][0] + assert "PADDLES_SQLALCHEMY_URL" in paddles_obj.env_vars + assert ( + "postgresql+psycopg2://admin:password@postgres:5432/paddles" + in paddles_obj.env_vars["PADDLES_SQLALCHEMY_URL"] + ) + + def test_service_specs_does_not_set_postgres_url_when_no_postgres(self): + config["containers"]["postgres"]["count"] = 0 + devstack = CephDevStack() + paddles_obj = devstack.service_specs["paddles"]["objects"][0] + assert "PADDLES_SQLALCHEMY_URL" not in paddles_obj.env_vars + + def test_service_specs_count_attribute(self): + devstack = CephDevStack() + assert devstack.service_specs["postgres"]["count"] == 1 + assert devstack.service_specs["testnode"]["count"] == 3 + + +class TestCephDevStackApply: + async def test_apply_calls_correct_method(self): + devstack = CephDevStack() + with patch.object(devstack, "pull", new_callable=AsyncMock) as mock_pull: + await devstack.apply("pull") + assert mock_pull.called is True + + async def test_apply_calls_create(self): + devstack = CephDevStack() + with patch.object(devstack, "create", new_callable=AsyncMock) as mock_create: + await devstack.apply("create") + assert mock_create.called is True + + async def test_apply_calls_start(self): + devstack = CephDevStack() + with patch.object(devstack, "start", new_callable=AsyncMock) as mock_start: + await devstack.apply("start") + assert mock_start.called is True + + +class TestCephDevStackPull: + async def test_pull_calls_pull_on_all_services(self): + devstack = CephDevStack() + # Override service_specs to control the objects + mock_postgres = AsyncMock() + mock_paddles = AsyncMock() + devstack.service_specs = { + "postgres": {"count": 1, "objects": [mock_postgres]}, + "paddles": {"count": 1, "objects": [mock_paddles]}, + } + with patch("ceph_devstack.logger.info"): + await devstack.pull() + mock_postgres.pull.assert_called_once() + mock_paddles.pull.assert_called_once() + + +class TestCephDevStackBuild: + async def test_build_calls_build_on_all_services(self): + devstack = CephDevStack() + mock_postgres = AsyncMock() + mock_paddles = AsyncMock() + devstack.service_specs = { + "postgres": {"count": 1, "objects": [mock_postgres]}, + "paddles": {"count": 1, "objects": [mock_paddles]}, + } + with patch("ceph_devstack.logger.info"): + await devstack.build() + mock_postgres.build.assert_called_once() + mock_paddles.build.assert_called_once() + + +class TestCephDevStackGetLogFile: + def test_get_log_file_with_run_name_and_job_id(self, tmp_path): + devstack = CephDevStack() + archive_dir = tmp_path / "archive" + archive_dir.mkdir() + run_name = "root-2025-01-01_00:00:00-orch:cephadm:smoke-small-main-distro-default-testnode" + run_dir = archive_dir / run_name + run_dir.mkdir() + job_dir = run_dir / "42" + job_dir.mkdir() + log_file = job_dir / "teuthology.log" + log_file.write_text("test log content") + + # Mock Teuthology to return our test archive_dir + with patch("ceph_devstack.resources.ceph.Teuthology") as MockTeuthology: + mock_teuthology = MagicMock() + mock_teuthology.archive_dir = archive_dir + MockTeuthology.return_value = mock_teuthology + result = devstack.get_log_file(run_name, "42") + assert str(result) == str(log_file) + + def test_get_log_file_with_run_name_only(self, tmp_path): + devstack = CephDevStack() + archive_dir = tmp_path / "archive" + archive_dir.mkdir() + run_name = "root-2025-01-01_00:00:00-orch:cephadm:smoke-small-main-distro-default-testnode" + run_dir = archive_dir / run_name + run_dir.mkdir() + job_dir = run_dir / "1" + job_dir.mkdir() + log_file = job_dir / "teuthology.log" + log_file.write_text("test log content") + + with patch("ceph_devstack.resources.ceph.Teuthology") as MockTeuthology: + mock_teuthology = MagicMock() + mock_teuthology.archive_dir = archive_dir + MockTeuthology.return_value = mock_teuthology + result = devstack.get_log_file(run_name, "") + assert str(result) == str(log_file) + + def test_get_log_file_raises_file_not_found_for_missing_job(self, tmp_path): + devstack = CephDevStack() + archive_dir = tmp_path / "archive" + archive_dir.mkdir() + run_name = "root-2025-01-01_00:00:00-orch:cephadm:smoke-small-main-distro-default-testnode" + run_dir = archive_dir / run_name + run_dir.mkdir() + + with patch("ceph_devstack.resources.ceph.Teuthology") as MockTeuthology: + mock_teuthology = MagicMock() + mock_teuthology.archive_dir = archive_dir + MockTeuthology.return_value = mock_teuthology + with pytest.raises(FileNotFoundError): + devstack.get_log_file(run_name, "99") + + def test_get_log_file_raises_file_not_found_for_missing_log(self, tmp_path): + devstack = CephDevStack() + archive_dir = tmp_path / "archive" + archive_dir.mkdir() + run_name = "root-2025-01-01_00:00:00-orch:cephadm:smoke-small-main-distro-default-testnode" + run_dir = archive_dir / run_name + run_dir.mkdir() + job_dir = run_dir / "1" + job_dir.mkdir() + + with patch("ceph_devstack.resources.ceph.Teuthology") as MockTeuthology: + mock_teuthology = MagicMock() + mock_teuthology.archive_dir = archive_dir + MockTeuthology.return_value = mock_teuthology + with pytest.raises(FileNotFoundError): + devstack.get_log_file(run_name, "1") + + def test_get_log_file_uses_most_recent_when_no_run_name(self, tmp_path): + devstack = CephDevStack() + archive_dir = tmp_path / "archive" + archive_dir.mkdir() + + # Create two runs + older_run = "root-2024-01-01_00:00:00-orch:cephadm:smoke-small-main-distro-default-testnode" + newer_run = "root-2025-01-01_00:00:00-orch:cephadm:smoke-small-main-distro-default-testnode" + + older_dir = archive_dir / older_run + older_dir.mkdir() + older_job = older_dir / "1" + older_job.mkdir() + (older_job / "teuthology.log").write_text("old log") + + newer_dir = archive_dir / newer_run + newer_dir.mkdir() + newer_job = newer_dir / "1" + newer_job.mkdir() + log_file = newer_job / "teuthology.log" + log_file.write_text("new log") + + # Override listdir behavior + def mock_listdir(path): + if str(path) == str(archive_dir): + return [older_run, newer_run] + if str(path) == str(newer_dir): + return ["1"] + return [] + + with patch("ceph_devstack.resources.ceph.Teuthology") as MockTeuthology: + mock_teuthology = MagicMock() + mock_teuthology.archive_dir = archive_dir + MockTeuthology.return_value = mock_teuthology + + with patch("os.listdir", side_effect=mock_listdir): + result = devstack.get_log_file("", "") + assert str(result) == str(log_file) + + def test_get_log_file_raises_too_many_jobs_when_multiple_and_no_job_id( + self, tmp_path + ): + devstack = CephDevStack() + archive_dir = tmp_path / "archive" + archive_dir.mkdir() + + run_name = "root-2025-01-01_00:00:00-orch:cephadm:smoke-small-main-distro-default-testnode" + run_dir = archive_dir / run_name + run_dir.mkdir() + + job1_dir = run_dir / "1" + job1_dir.mkdir() + job1_log = job1_dir / "teuthology.log" + job1_log.write_text("job 1 log") + + job2_dir = run_dir / "2" + job2_dir.mkdir() + job2_log = job2_dir / "teuthology.log" + job2_log.write_text("job 2 log") + + with patch("ceph_devstack.resources.ceph.Teuthology") as MockTeuthology: + mock_teuthology = MagicMock() + mock_teuthology.archive_dir = archive_dir + MockTeuthology.return_value = mock_teuthology + + def mock_listdir(path): + if str(path) == str(run_dir): + return ["1", "2"] + return [] + + with ( + patch("os.listdir", side_effect=mock_listdir), + pytest.raises(TooManyJobsFound), + ): + devstack.get_log_file(run_name, "") + + +class TestCephDevStackRemove: + async def test_remove_calls_remove_on_all_containers(self): + devstack = CephDevStack() + mock_postgres = AsyncMock() + mock_paddles = AsyncMock() + devstack.service_specs = { + "postgres": {"count": 1, "objects": [mock_postgres]}, + "paddles": {"count": 1, "objects": [mock_paddles]}, + } + with patch("ceph_devstack.resources.ceph.CephDevStackNetwork") as MockNetwork: + mock_network_instance = MagicMock() + mock_network_instance.remove = AsyncMock() + MockNetwork.return_value = mock_network_instance + with patch("ceph_devstack.resources.ceph.SSHKeyPair") as MockSecret: + mock_secret_instance = MagicMock() + mock_secret_instance.remove = AsyncMock() + MockSecret.return_value = mock_secret_instance + with patch("ceph_devstack.logger.info"): + await devstack.remove() + mock_postgres.remove.assert_called_once() + mock_paddles.remove.assert_called_once() + mock_network_instance.remove.assert_called_once() + mock_secret_instance.remove.assert_called_once() + + +class TestCephDevStackStop: + async def test_stop_calls_stop_on_all_containers(self): + devstack = CephDevStack() + mock_postgres = AsyncMock() + mock_paddles = AsyncMock() + devstack.service_specs = { + "postgres": {"count": 1, "objects": [mock_postgres]}, + "paddles": {"count": 1, "objects": [mock_paddles]}, + } + with patch("ceph_devstack.logger.info"): + await devstack.stop() + mock_postgres.stop.assert_called_once() + mock_paddles.stop.assert_called_once() + + +class TestCephDevStackWait: + async def test_wait_returns_process_id(self): + devstack = CephDevStack() + mock_container = AsyncMock() + mock_container.name = "teuthology" + mock_container.wait = AsyncMock(return_value=42) + devstack.service_specs = { + "teuthology": {"count": 1, "objects": [mock_container]}, + } + result = await devstack.wait("teuthology") + assert result == 42 + + async def test_wait_returns_one_for_nonexistent_container(self): + devstack = CephDevStack() + mock_container = AsyncMock() + mock_container.name = "teuthology" + devstack.service_specs = { + "teuthology": {"count": 1, "objects": [mock_container]}, + } + result = await devstack.wait("nonexistent") + assert result == 1 + + +class TestCephDevStackLogs: + async def test_logs_with_locate_true(self, tmp_path): + devstack = CephDevStack() + archive_dir = tmp_path / "archive" + archive_dir.mkdir() + run_name = "root-2025-01-01_00:00:00-orch:cephadm:smoke-small-main-distro-default-testnode" + run_dir = archive_dir / run_name + run_dir.mkdir() + job_dir = run_dir / "1" + job_dir.mkdir() + log_file = job_dir / "teuthology.log" + log_file.write_text("test log content") + + import contextlib + import io + + f = io.StringIO() + with patch("ceph_devstack.resources.ceph.Teuthology") as MockTeuthology: + mock_teuthology = MagicMock() + mock_teuthology.archive_dir = archive_dir + MockTeuthology.return_value = mock_teuthology + + def mock_listdir(path): + if str(path) == str(archive_dir): + return [run_name] + if str(path) == str(run_dir): + return ["1"] + return [] + + with ( + patch("os.listdir", side_effect=mock_listdir), + contextlib.redirect_stdout(f), + ): + await devstack.logs(locate=True) + output = f.getvalue() + assert str(log_file) in output + + async def test_logs_with_locate_false(self, tmp_path): + devstack = CephDevStack() + archive_dir = tmp_path / "archive" + archive_dir.mkdir() + run_name = "root-2025-01-01_00:00:00-orch:cephadm:smoke-small-main-distro-default-testnode" + run_dir = archive_dir / run_name + run_dir.mkdir() + job_dir = run_dir / "1" + job_dir.mkdir() + log_file = job_dir / "teuthology.log" + log_file.write_text("test log content") + + import contextlib + import io + + f = io.StringIO() + with patch("ceph_devstack.resources.ceph.Teuthology") as MockTeuthology: + mock_teuthology = MagicMock() + mock_teuthology.archive_dir = archive_dir + MockTeuthology.return_value = mock_teuthology + + def mock_listdir(path): + if str(path) == str(archive_dir): + return [run_name] + if str(path) == str(run_dir): + return ["1"] + return [] + + with ( + patch("os.listdir", side_effect=mock_listdir), + contextlib.redirect_stdout(f), + ): + await devstack.logs(locate=False) + output = f.getvalue() + assert "test log content" in output + + async def test_logs_with_missing_file_shows_error(self, tmp_path, caplog): + devstack = CephDevStack() + archive_dir = tmp_path / "archive" + archive_dir.mkdir() + run_name = "root-2025-01-01_00:00:00-orch:cephadm:smoke-small-main-distro-default-testnode" + run_dir = archive_dir / run_name + run_dir.mkdir() + + with patch("ceph_devstack.resources.ceph.Teuthology") as MockTeuthology: + mock_teuthology = MagicMock() + mock_teuthology.archive_dir = archive_dir + MockTeuthology.return_value = mock_teuthology + + def mock_listdir(path): + if str(path) == str(archive_dir): + return [run_name] + if str(path) == str(run_dir): + return ["1"] + return [] + + with patch("os.listdir", side_effect=mock_listdir): + await devstack.logs() + assert "No log file found" in caplog.text + + +class TestCephDevStackInit: + def test_init_without_postgres(self): + config["containers"] = { + "postgres": {"image": "postgres:latest", "count": 0}, + "paddles": {"image": "paddles:latest", "count": 1}, + "beanstalk": {"image": "beanstalk:latest", "count": 1}, + "pulpito": {"image": "pulpito:latest", "count": 1}, + "testnode": {"image": "testnode:latest", "count": 3}, + "teuthology": {"image": "teuthology:latest", "count": 1}, + "archive": {"image": "archive:latest", "count": 1}, + } + devstack = CephDevStack() + assert "archive" in devstack.service_specs + assert "postgres" not in devstack.service_specs diff --git a/tests/resources/ceph/test_env_vars.py b/tests/resources/ceph/test_env_vars.py new file mode 100644 index 00000000..96a21fa8 --- /dev/null +++ b/tests/resources/ceph/test_env_vars.py @@ -0,0 +1,110 @@ +import pytest + +from ceph_devstack.resources.ceph import containers + + +ANY_VALUE = "ANY_VALUE" + + +class _TestContainerEnvVars: + @pytest.fixture(scope="class") + def cls(self): + raise NotImplementedError + + @pytest.fixture(scope="class") + def env_vars(self): + # return {} + raise NotImplementedError + + def test_env_vars(self, cls, env_vars): + obj = cls() + if env_vars == {}: + assert obj.env_vars == env_vars + else: + for env_var, value in env_vars.items(): + assert env_var in obj.env_vars + assert obj.env_vars[env_var] == value + + +class TestPostgres(_TestContainerEnvVars): + @pytest.fixture(scope="class") + def cls(self): + return containers.Postgres + + @pytest.fixture(scope="class") + def env_vars(self): + return { + "POSTGRES_USER": "root", + "POSTGRES_PASSWORD": "password", + "APP_DB_USER": "admin", + "APP_DB_PASS": "password", + "APP_DB_NAME": "paddles", + } + + +class TestPaddles(_TestContainerEnvVars): + @pytest.fixture(scope="class") + def cls(self): + return containers.Paddles + + @pytest.fixture(scope="class") + def env_vars(self): + return { + "PADDLES_SERVER_HOST": "0.0.0.0", + } + + +class TestPulpito(_TestContainerEnvVars): + @pytest.fixture(scope="class") + def cls(self): + return containers.Pulpito + + @pytest.fixture(scope="class") + def env_vars(self): + return { + "PULPITO_PADDLES_ADDRESS": "http://paddles:8080", + } + + +class TestTestNode(_TestContainerEnvVars): + @pytest.fixture(scope="class") + def cls(self): + return containers.TestNode + + @pytest.fixture(scope="class") + def env_vars(self): + return { + "CEPH_VOLUME_ALLOW_LOOP_DEVICES": "true", + } + + +class TestTeuthology(_TestContainerEnvVars): + @pytest.fixture(scope="class") + def cls(self): + return containers.Teuthology + + @pytest.fixture(scope="class") + def env_vars(self): + return { + "SSH_PRIVKEY": "", + "SSH_PRIVKEY_FILE": "", + "TEUTHOLOGY_MACHINE_TYPE": "", + "TEUTHOLOGY_TESTNODES": "", + "TEUTHOLOGY_BRANCH": "", + "TEUTHOLOGY_CEPH_BRANCH": "", + "TEUTHOLOGY_CEPH_REPO": "", + "TEUTHOLOGY_SUITE": "", + "TEUTHOLOGY_SUITE_BRANCH": "", + "TEUTHOLOGY_SUITE_REPO": "", + "TEUTHOLOGY_SUITE_EXTRA_ARGS": "", + } + + +class TestBeanstalk(_TestContainerEnvVars): + @pytest.fixture(scope="class") + def cls(self): + return containers.Beanstalk + + @pytest.fixture(scope="class") + def env_vars(self): + return {} diff --git a/tests/resources/ceph/test_requirements_ceph.py b/tests/resources/ceph/test_requirements_ceph.py new file mode 100644 index 00000000..c4ccd74f --- /dev/null +++ b/tests/resources/ceph/test_requirements_ceph.py @@ -0,0 +1,262 @@ +from unittest.mock import AsyncMock, MagicMock, patch + +from ceph_devstack import config +from ceph_devstack.resources.ceph import CephDevStack + +from ceph_devstack.resources.ceph.requirements import ( + HasSudo, + LoopControlDeviceExists, + LoopControlDeviceWriteable, + SELinuxModule, +) + + +class TestHasSudo: + def setup_method(self): + self.req = HasSudo() + + async def test_has_sudo_check_true(self): + mock_proc = AsyncMock() + mock_proc.wait = AsyncMock(return_value=0) + with patch.object(self.req.host, "arun", return_value=mock_proc): + result = await self.req.check() + assert result is True + + async def test_has_sudo_check_false(self): + mock_proc = AsyncMock() + mock_proc.wait = AsyncMock(return_value=1) + with patch.object(self.req.host, "arun", return_value=mock_proc): + result = await self.req.check() + assert result is False + + def test_has_sudo_check_cmd(self): + assert self.req.check_cmd == ["sudo", "true"] + + def test_has_sudo_suggest_msg(self): + assert self.req.suggest_msg == "sudo access is required" + + +class TestLoopControlDeviceExists: + def setup_method(self): + self.req = LoopControlDeviceExists() + + async def test_loop_control_exists_true(self): + mock_proc = AsyncMock() + mock_proc.wait = AsyncMock(return_value=0) + with patch.object(self.req.host, "arun", return_value=mock_proc): + result = await self.req.check() + assert result is True + + async def test_loop_control_exists_false(self): + mock_proc = AsyncMock() + mock_proc.wait = AsyncMock(return_value=1) + with patch.object(self.req.host, "arun", return_value=mock_proc): + result = await self.req.check() + assert result is False + + def test_loop_control_exists_check_cmd(self): + assert self.req.check_cmd == ["test", "-e", "/dev/loop-control"] + + def test_loop_control_exists_fix_cmd(self): + assert self.req.fix_cmd == ["sudo", "modprobe", "loop"] + + def test_loop_control_exists_suggest_msg(self): + assert self.req.suggest_msg == "/dev/loop-control does not exist" + + +class TestLoopControlDeviceWriteable: + def setup_method(self): + self.req = LoopControlDeviceWriteable() + + async def test_loop_control_writeable_check_true(self): + mock_proc = AsyncMock() + mock_proc.wait = AsyncMock(return_value=0) + with patch.object(self.req.host, "arun", return_value=mock_proc): + result = await self.req.check() + assert result is True + + async def test_loop_control_writeable_check_false_local(self): + mock_check_proc = AsyncMock() + mock_check_proc.wait = AsyncMock(return_value=1) + + mock_stat_proc = MagicMock() + mock_stat_proc.communicate = MagicMock(return_value=(b"disk", 0)) + + mock_whoami_proc = MagicMock() + mock_whoami_proc.communicate = MagicMock(return_value=(b"testuser", 0)) + + async def side_effect_arun(args): + if "stat" in args: + return mock_stat_proc + if "whoami" in args: + return mock_whoami_proc + return mock_check_proc + + with ( + patch.object(self.req.host, "arun", side_effect=side_effect_arun), + patch.object(self.req.host, "type", "local"), + ): + result = await self.req.check() + assert result is False + assert "usermod" in " ".join(self.req.fix_cmd) + + async def test_loop_control_writeable_check_false_remote(self): + mock_check_proc = AsyncMock() + mock_check_proc.wait = AsyncMock(return_value=1) + + mock_stat_proc = MagicMock() + mock_stat_proc.communicate = MagicMock(return_value=(b"disk", 0)) + + mock_whoami_proc = MagicMock() + mock_whoami_proc.communicate = MagicMock(return_value=(b"testuser", 0)) + + async def side_effect_arun(args): + if "stat" in args: + return mock_stat_proc + if "whoami" in args: + return mock_whoami_proc + return mock_check_proc + + with ( + patch.object(self.req.host, "arun", side_effect=side_effect_arun), + patch.object(self.req.host, "type", "remote"), + ): + result = await self.req.check() + assert result is False + assert "chgrp" in " ".join(self.req.fix_cmd) + + +class TestSELinuxModule: + def setup_method(self): + self.req = SELinuxModule() + + async def test_selinux_module_check_true(self): + mock_proc = AsyncMock() + mock_proc.stdout = AsyncMock() + mock_proc.stdout.read = AsyncMock(return_value=b"ceph_devstack\nother_module\n") + mock_proc.wait = AsyncMock(return_value=0) + with patch.object(self.req.host, "arun", return_value=mock_proc): + result = await self.req.check() + assert result is True + + async def test_selinux_module_check_false(self): + mock_proc = AsyncMock() + mock_proc.stdout = AsyncMock() + mock_proc.stdout.read = AsyncMock( + return_value=b"other_module\nanother_module\n" + ) + mock_proc.wait = AsyncMock(return_value=0) + with patch.object(self.req.host, "arun", return_value=mock_proc): + result = await self.req.check() + assert result is False + + async def test_selinux_module_check_empty_output(self): + mock_proc = AsyncMock() + mock_proc.stdout = AsyncMock() + mock_proc.stdout.read = AsyncMock(return_value=b"") + mock_proc.wait = AsyncMock(return_value=0) + with patch.object(self.req.host, "arun", return_value=mock_proc): + result = await self.req.check() + assert result is False + + +class TestSELinuxModuleFixCmd: + def test_selinux_module_fix_cmd_local(self): + class MockLocalHost: + type = "local" + + with patch.object( + SELinuxModule, + "host", + MockLocalHost(), + ): + req = SELinuxModule() + assert req.fix_cmd[:3] == [ + "sudo", + "semodule", + "-i", + ] + assert req.fix_cmd[3].endswith("ceph_devstack.pp") + + def test_selinux_module_fix_cmd_remote(self): + class MockRemoteHost: + type = "remote" + + with patch.object( + SELinuxModule, + "host", + MockRemoteHost(), + ): + req = SELinuxModule() + assert req.fix_cmd[:7] == [ + "podman", + "machine", + "ssh", + "--", + "sudo", + "semodule", + "-i", + ] + assert req.fix_cmd[7].endswith("ceph_devstack.pp") + + +class TestCephDevStackCheckRequirements: + async def test_check_requirements_returns_true_when_all_pass(self): + devstack = CephDevStack() + devstack.service_specs = {} + config["containers"] = {} + + with ( + patch("ceph_devstack.resources.ceph.HasSudo") as MockHasSudo, + patch( + "ceph_devstack.resources.ceph.LoopControlDeviceExists" + ) as MockLoopCtrl, + patch( + "ceph_devstack.resources.ceph.LoopControlDeviceWriteable" + ) as MockLoopCtrlWrite, + patch("ceph_devstack.host.host.selinux_enforcing") as mock_selinux, + ): + mock_has_sudo = AsyncMock() + mock_has_sudo.evaluate = AsyncMock(return_value=True) + MockHasSudo.return_value = mock_has_sudo + mock_loop_ctrl = AsyncMock() + mock_loop_ctrl.evaluate = AsyncMock(return_value=True) + MockLoopCtrl.return_value = mock_loop_ctrl + mock_loop_ctrl_write = AsyncMock() + mock_loop_ctrl_write.evaluate = AsyncMock(return_value=True) + MockLoopCtrlWrite.return_value = mock_loop_ctrl_write + mock_selinux.return_value = False + result = await devstack.check_requirements() + assert result is True + + async def test_check_requirements_returns_false_when_repo_missing(self): + devstack = CephDevStack() + devstack.service_specs = {} + config["containers"] = { + "custom": {"repo": "/nonexistent/path"}, + } + + with ( + patch("ceph_devstack.resources.ceph.HasSudo") as MockHasSudo, + patch( + "ceph_devstack.resources.ceph.LoopControlDeviceExists" + ) as MockLoopCtrl, + patch( + "ceph_devstack.resources.ceph.LoopControlDeviceWriteable" + ) as MockLoopCtrlWrite, + patch("ceph_devstack.host.host.selinux_enforcing") as mock_selinux, + patch("ceph_devstack.host.host.path_exists") as mock_path_exists, + ): + mock_has_sudo = AsyncMock() + mock_has_sudo.evaluate = AsyncMock(return_value=True) + MockHasSudo.return_value = mock_has_sudo + mock_loop_ctrl = AsyncMock() + mock_loop_ctrl.evaluate = AsyncMock(return_value=True) + MockLoopCtrl.return_value = mock_loop_ctrl + mock_loop_ctrl_write = AsyncMock() + mock_loop_ctrl_write.evaluate = AsyncMock(return_value=True) + MockLoopCtrlWrite.return_value = mock_loop_ctrl_write + mock_selinux.return_value = False + mock_path_exists.return_value = False + result = await devstack.check_requirements() + assert result is False diff --git a/tests/resources/ceph/test_ssh_keypair.py b/tests/resources/ceph/test_ssh_keypair.py new file mode 100644 index 00000000..c1781afb --- /dev/null +++ b/tests/resources/ceph/test_ssh_keypair.py @@ -0,0 +1,109 @@ +import pytest +from unittest.mock import AsyncMock, patch + + +from ceph_devstack.resources.ceph import SSHKeyPair +from tests.resources.test_misc import TestMiscResource as _TestMiscResource + + +class TestSSHKeyPair(_TestMiscResource): + @pytest.fixture + def cls(self): + return SSHKeyPair + + def test_name(self, cls): + obj = cls() + assert obj.name == "id_rsa" + + def test_repr(self, cls): + obj = cls() + class_name = cls.__name__ + assert repr(obj) == f'{class_name}(name="id_rsa")' + obj = cls(name="foo") + assert repr(obj) == f'{class_name}(name="foo")' + + def test_ssh_key_pair_default_paths(self, cls): + pair = SSHKeyPair() + assert pair.privkey_path == "id_rsa" + assert pair.pubkey_path == "id_rsa.pub" + + async def test_action_for_each_key(self, cls, action): + with patch.object(cls, "cmd"): + obj = cls() + cmds = getattr(obj, f"{action}_cmds") + assert len(cmds) == 2 + assert obj.format_cmd(cmds[0])[-1] == obj.privkey_path + assert obj.format_cmd(cmds[1])[-1] == obj.pubkey_path + + def test_ssh_key_pair_cmd_vars(self, cls): + obj = cls() + assert "name" in obj.cmd_vars + assert "privkey_path" in obj.cmd_vars + assert "pubkey_path" in obj.cmd_vars + + @pytest.mark.parametrize("exists", [True, False]) + async def test_create_when_not_exists(self, cls, exists): + obj = cls() + with ( + patch.object(obj, "exists", return_value=exists), + patch.object(obj, "cmd") as mock_cmd, + ): + mock_proc = AsyncMock() + mock_proc.wait = AsyncMock(return_value=(0 if exists else 1)) + mock_cmd.return_value = mock_proc + await obj.create() + assert len(mock_cmd.call_args_list) == (0 if exists else 3) + + async def test_ssh_key_pair_exists_both_present(self, cls): + obj = cls() + with patch.object(obj, "cmd") as mock_cmd: + mock_proc1 = AsyncMock() + mock_proc1.wait = AsyncMock(return_value=0) + mock_proc2 = AsyncMock() + mock_proc2.wait = AsyncMock(return_value=0) + mock_cmd.side_effect = [mock_proc1, mock_proc2] + result = await obj.exists() + assert result is True + assert mock_cmd.call_count == 2 + + async def test_ssh_key_pair_exists_first_missing(self, cls): + obj = cls() + with patch.object(obj, "cmd") as mock_cmd: + mock_proc1 = AsyncMock() + mock_proc1.wait = AsyncMock(return_value=1) + mock_cmd.return_value = mock_proc1 + result = await obj.exists() + assert result is False + + async def test_ssh_key_pair_exists_second_missing(self, cls): + obj = cls() + with patch.object(obj, "cmd") as mock_cmd: + mock_proc1 = AsyncMock() + mock_proc1.wait = AsyncMock(return_value=0) + mock_proc2 = AsyncMock() + mock_proc2.wait = AsyncMock(return_value=1) + mock_cmd.side_effect = [mock_proc1, mock_proc2] + result = await obj.exists() + assert result is False + + async def test_ssh_key_pair_exists_when_already_exists(self, cls): + obj = cls() + with ( + patch.object(obj, "exists") as mock_exists, + patch.object(obj, "_get_ssh_keys") as mock_get_keys, + patch.object(obj, "cmd") as mock_cmd, + ): + mock_exists.return_value = True + await obj.create() + mock_exists.assert_called_once() + mock_get_keys.assert_not_called() + mock_cmd.assert_not_called() + + async def test_ssh_key_pair_remove_calls_both_commands(self, cls): + obj = cls() + with patch.object(obj, "cmd") as mock_cmd: + mock_proc = AsyncMock() + mock_proc.wait = AsyncMock(return_value=0) + mock_cmd.return_value = mock_proc + await obj.remove() + assert mock_cmd.call_count == 2 diff --git a/tests/resources/test_container.py b/tests/resources/test_container.py index 8a274c3a..0ac42db3 100644 --- a/tests/resources/test_container.py +++ b/tests/resources/test_container.py @@ -1,6 +1,8 @@ import json +import os import pytest +from pathlib import Path from unittest.mock import patch, AsyncMock from ceph_devstack import config @@ -10,8 +12,17 @@ ) -class TestContainer(_TestPodmanResource): - @pytest.fixture(scope="class") +class _TestContainerBase: + @pytest.fixture + def cls(self): + return Container + + def setup_method(self): + config["containers"]["container"] = {"image": "example.com/image:latest"} + + +class TestContainerResource(_TestPodmanResource, _TestContainerBase): + @pytest.fixture def cls(self): return Container @@ -21,17 +32,113 @@ def cls(self): def action(self, request): return request.param - def setup_method(self): - config["containers"]["container"] = {"image": "example.com/image:latest"} + async def test_action_calls_cmd_with_correct_args(self, cls, action): + if action == "build": + config["containers"][cls.__name__.lower()]["repo"] = "/repo_path" + obj = cls() + with patch.object(obj, "cmd") as mock_cmd: + mock_proc = AsyncMock() + mock_cmd.return_value = mock_proc + action_cmd = "rm" if action == "remove" else action + await getattr(obj, action)() + if action == "create": + assert len(mock_cmd.call_args_list) == 2 + assert "inspect" in mock_cmd.call_args_list[0][0][0] + assert action_cmd in mock_cmd.call_args_list[-1][0][0] + else: + mock_cmd.assert_called_once() + call_args = mock_cmd.call_args[0][0] + assert action_cmd in call_args + + async def test_empty_cmd_skips_action(self, cls, action): + with patch.object(cls, "cmd"): + obj = cls() + setattr(obj, f"{action}_cmd", []) + await getattr(obj, action)() + obj.cmd.assert_not_awaited() + + async def test_action_cmd_called_with_stream_output(self, cls, action): + if action == "remove": + pytest.skip("remove action doesn't stream output") + if action == "build": + config["containers"][cls.__name__.lower()]["repo"] = "/repo_path" + with patch.object(cls, "cmd") as mock_cmd: + obj = cls() + await getattr(obj, action)() + _, kwargs = mock_cmd.call_args + assert kwargs.get("stream_output") is True + + async def test_build_action_skips_when_no_repo(self, cls): + config["containers"][cls.__name__.lower()]["repo"] = "" + obj = cls() + with patch.object(obj, "cmd") as mock_cmd: + await obj.build() + mock_cmd.assert_not_called() + + async def test_pull_action_skips_localhost_images(self, cls): + config["containers"]["container"]["image"] = "localhost/image:latest" + obj = cls() + with patch.object(obj, "cmd") as mock_cmd: + await obj.pull() + mock_cmd.assert_not_called() + + @pytest.mark.parametrize( + "output,rc,expected", ([b"12345", 0, 12345], [b"error", 1, 1]) + ) + async def test_wait_returns_output_on_success(self, cls, output, rc, expected): + obj = cls() + with patch.object(obj, "cmd") as mock_cmd: + mock_proc = AsyncMock() + mock_proc.communicate = AsyncMock(return_value=(output, b"")) + mock_proc.returncode = rc + mock_cmd.return_value = mock_proc + result = await obj.wait() + assert expected == result + + async def test_wait_action_returns_error_code_on_failure(self, cls): + obj = cls() + with patch.object(obj, "cmd") as mock_cmd: + mock_proc = AsyncMock() + mock_proc.communicate = AsyncMock(return_value=(b"", b"error occurred")) + mock_proc.returncode = 130 + mock_cmd.return_value = mock_proc + result = await obj.wait() + assert result == 130 + +class TestContainerInit(_TestContainerBase): + def test_init_sets_env_vars_from_class(self, cls): + Container.env_vars = {"TEST_VAR": "default_value"} + obj = cls() + assert "TEST_VAR" in obj.env_vars + assert obj.env_vars["TEST_VAR"] == "default_value" + + def test_init_overrides_env_vars_from_environment(self, cls): + Container.env_vars = {"TEST_VAR": "default"} + with patch.dict(os.environ, {"TEST_VAR": "env_value"}): + obj = cls() + assert obj.env_vars["TEST_VAR"] == "env_value" + + def test_init_does_not_override_missing_env_vars(self, cls): + Container.env_vars = {"TEST_VAR": "default"} + with patch.dict(os.environ, {}, clear=False): + if "TEST_VAR" in os.environ: + del os.environ["TEST_VAR"] + obj = cls() + assert obj.env_vars["TEST_VAR"] == "default" + + +class TestContainerExists(_TestContainerBase): @pytest.mark.parametrize("rc,res", ([0, True], [1, False])) - async def test_exists_yes(self, cls, rc, res): + async def test_exists(self, cls, rc, res): with patch.object(cls, "cmd"): obj = cls() obj.cmd.return_value = AsyncMock() obj.cmd.return_value.wait.return_value = rc assert await obj.exists() == res + +class TestContainerRunning(_TestContainerBase): async def test_is_running_yes(self, cls): with patch.object(cls, "cmd"): obj = cls() @@ -64,9 +171,60 @@ async def test_is_running_no_bc_dne(self, cls): obj.cmd.return_value = AsyncMock(returncode=1) assert await obj.is_running() is False - async def test_empty_cmd_skips_action(self, cls, action): - with patch.object(cls, "cmd"): - obj = cls() - setattr(obj, f"{action}_cmd", []) - await getattr(obj, action)() - obj.cmd.assert_not_awaited() + +class TestContainerImage(_TestContainerBase): + def test_image_returns_config_image_when_no_repo(self, cls): + obj = cls() + assert obj.image == "example.com/image:latest" + + +class TestContainerImageTag(_TestContainerBase): + def test_image_tag_with_colon(self, cls): + obj = cls() + config["containers"]["container"] = {"image": "example.com/image:v1.0"} + assert obj.image_tag == "v1.0" + + def test_image_tag_without_colon(self, cls): + obj = cls() + config["containers"]["container"] = {"image": "example.com/image"} + assert obj.image_tag == "latest" + + +class TestContainerCwd(_TestContainerBase): + def test_cwd_returns_repo_when_exists(self, cls): + obj = cls() + with patch.object(type(obj), "repo", Path("/path/to/repo")): + assert obj.cwd == Path("/path/to/repo") + + def test_cwd_returns_dot_when_no_repo(self, cls): + obj = cls() + assert obj.cwd == "." + + +class TestContainerAddEnvToArgs(_TestContainerBase): + def test_add_env_to_args_inserts_env_vars(self, cls): + obj = cls() + obj.env_vars = {"KEY1": "value1", "KEY2": "value2"} + args = ["podman", "run", "image"] + result = obj.add_env_to_args(args) + assert result[-1] == "image" # last element is preserved + assert "-e" in result + assert "KEY1=value1" in result + assert "KEY2=value2" in result + + def test_add_env_to_args_skips_empty_values(self, cls): + obj = cls() + obj.env_vars = {"KEY1": "value1", "KEY2": None, "KEY3": ""} + args = ["podman", "run", "image"] + result = obj.add_env_to_args(args) + assert "KEY1=value1" in result + assert "KEY2=" not in result + assert "KEY3=" not in result + + def test_add_env_to_args_preserves_order(self, cls): + obj = cls() + obj.env_vars = {"KEY": "value"} + args = ["podman", "run", "image"] + result = obj.add_env_to_args(args) + assert result[-1] == "image" + assert result.index("-e") < result.index("image") diff --git a/tests/resources/test_misc.py b/tests/resources/test_misc.py new file mode 100644 index 00000000..3f052ec0 --- /dev/null +++ b/tests/resources/test_misc.py @@ -0,0 +1,46 @@ +import pytest + +from unittest.mock import patch, AsyncMock + +from ceph_devstack.resources.misc import Network, Secret + +from .test_podmanresource import ( + TestPodmanResource as _TestPodmanResource, +) + + +class TestMiscResource(_TestPodmanResource): + @pytest.fixture(scope="class", params=[Network, Secret]) + def cls(self, request): + return request.param + + @pytest.fixture(scope="class", params=["create", "exists", "remove"]) + def action(self, request): + return request.param + + async def test_exists_means_inspect(self, cls): + obj = cls() + assert "inspect" in obj.exists_cmd + + @pytest.mark.parametrize("rc,expected", [[0, True], [1, False]]) + async def test_exists(self, cls, rc, expected): + obj = cls() + with patch.object(obj, "cmd") as mock_cmd: + mock_proc = AsyncMock() + mock_proc.wait = AsyncMock(return_value=rc) + mock_cmd.return_value = mock_proc + result = await obj.exists() + assert result is expected + + @pytest.mark.parametrize("exists", [True, False]) + async def test_create_when_not_exists(self, cls, exists): + obj = cls() + with ( + patch.object(obj, "exists", return_value=exists), + patch.object(obj, "cmd") as mock_cmd, + ): + mock_proc = AsyncMock() + mock_proc.wait = AsyncMock(return_value=(0 if exists else 1)) + mock_cmd.return_value = mock_proc + await obj.create() + assert len(mock_cmd.call_args_list) == (0 if exists else 1) diff --git a/tests/resources/test_podmanresource.py b/tests/resources/test_podmanresource.py index 21a0908a..f1e6da5b 100644 --- a/tests/resources/test_podmanresource.py +++ b/tests/resources/test_podmanresource.py @@ -4,6 +4,7 @@ from subprocess import CalledProcessError from unittest.mock import patch + from ceph_devstack.resources import PodmanResource @@ -28,6 +29,9 @@ def test_format_cmd(self, cls): res = obj.format_cmd(["foo", "{name}", "bar", "x{name}x"]) assert res == ["foo", "pr", "bar", "xprx"] + def test_cmd_vars_contains_name(self, cls): + assert "name" in cls.cmd_vars + def test_repr(self, cls): obj = cls() class_name = cls.__name__ diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 00000000..8bdcd4ab --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,120 @@ +import tomlkit + +from ceph_devstack import config, Config + + +class TestConfigDump: + def test_config_dump_returns_string(self): + result = config.dump() + assert isinstance(result, str) + + def test_config_dump_is_valid_toml(self): + result = config.dump() + parsed = tomlkit.parse(result) + assert isinstance(parsed, tomlkit.TOMLDocument) + + def test_config_contents_basic(self): + result = config.dump() + requires = [ + "containers", + "data_dir", + "postgres", + "beanstalk", + "paddles", + "pulpito", + "testnode", + "teuthology", + "archive", + ] + for item in requires: + assert item in result + + +class TestConfigGetValue: + def test_get_value_simple_key(self): + result = config.get_value("data_dir") + assert isinstance(result, str) + + def test_get_value_nested_count(self): + result = config.get_value("containers.testnode.count") + assert result == "3" + + def test_get_value_nested_loop_device_size(self): + result = config.get_value("containers.testnode.loop_device_size") + assert result == "5G" + + def test_get_value_nested_image(self): + result = config.get_value("containers.testnode.image") + assert "quay.io/ceph-infra/teuthology-testnode:main" in result + + def test_get_value_returns_string_for_int(self): + result = config.get_value("containers.testnode.count") + assert result == "3" + assert isinstance(result, str) + + +class TestConfigSet: + def test_set_value_simple_key(self, tmp_path): + test_config = Config() + config_file = tmp_path / "test_config.toml" + config_file.write_text("") + test_config.load(config_file) + test_config.set_value("test_key", "test_value") + assert test_config["test_key"] == "test_value" + + def test_set_value_nested_key(self, tmp_path): + test_config = Config() + config_file = tmp_path / "test_config.toml" + config_file.write_text("") + test_config.load(config_file) + test_config.set_value("test_section.test_key", "test_value") + assert test_config["test_section"]["test_key"] == "test_value" + + def test_set_value_updates_user_obj(self, tmp_path): + test_config = Config() + config_file = tmp_path / "test_config.toml" + config_file.write_text("") + test_config.load(config_file) + test_config.set_value("new_key", "new_value") + assert "new_key" in test_config.user_obj + + def test_set_value_creates_intermediate_sections(self, tmp_path): + test_config = Config() + config_file = tmp_path / "test_config.toml" + config_file.write_text("") + test_config.load(config_file) + test_config.set_value("deep.nested.key", "value") + assert test_config.user_obj["deep"]["nested"]["key"] == "value" + + def test_set_value_overrides_existing(self, tmp_path): + test_config = Config() + config_file = tmp_path / "test_config.toml" + config_file.write_text("") + test_config.load(config_file) + original_count = test_config["containers"]["testnode"]["count"] + new_count = original_count + 2 + test_config.set_value("containers.testnode.count", str(new_count)) + assert test_config["containers"]["testnode"]["count"] != original_count + assert test_config["containers"]["testnode"]["count"] == new_count + + +class TestConfigDefaults: + def test_config_defaults(self): + assert config == { + "data_dir": "~/.local/share/ceph-devstack", + "containers": { + "archive": {"image": "python:alpine"}, + "beanstalk": {"image": "quay.io/ceph-infra/teuthology-beanstalkd:main"}, + "paddles": {"image": "quay.io/ceph-infra/paddles:main"}, + "postgres": { + "image": "quay.io/ceph-infra/teuthology-postgresql:latest" + }, + "pulpito": {"image": "quay.io/ceph-infra/pulpito:main"}, + "testnode": { + "count": 3, + "loop_device_size": "5G", + "image": "quay.io/ceph-infra/teuthology-testnode:main", + }, + "teuthology": {"image": "quay.io/ceph-infra/teuthology-dev:main"}, + }, + } diff --git a/tests/test_deep_merge.py b/tests/test_deep_merge.py new file mode 100644 index 00000000..bb1624a0 --- /dev/null +++ b/tests/test_deep_merge.py @@ -0,0 +1,71 @@ +from ceph_devstack import deep_merge + + +class TestDeepMerge: + def test_deep_merge_empty_maps(self): + result = deep_merge() + assert result == {} + + def test_deep_merge_single_map(self): + m = {"a": 1, "b": 2} + result = deep_merge(m) + assert result == m + + def test_deep_merge_two_maps_no_overlap(self): + m1 = {"a": 1} + m2 = {"b": 2} + result = deep_merge(m1, m2) + assert result == {"a": 1, "b": 2} + + def test_deep_merge_two_maps_with_overlap(self): + m1 = {"a": 1, "b": 2} + m2 = {"b": 3, "c": 4} + result = deep_merge(m1, m2) + assert result == {"a": 1, "b": 3, "c": 4} + + def test_deep_merge_nested_dicts(self): + m1 = {"a": {"x": 1, "y": 2}} + m2 = {"a": {"y": 3, "z": 4}} + result = deep_merge(m1, m2) + assert result == {"a": {"x": 1, "y": 3, "z": 4}} + + def test_deep_merge_three_maps(self): + m1 = {"a": 1} + m2 = {"b": 2} + m3 = {"c": 3} + result = deep_merge(m1, m2, m3) + assert result == {"a": 1, "b": 2, "c": 3} + + def test_deep_merge_nested_override(self): + m1 = {"outer": {"inner": "default", "keep": "value"}} + m2 = {"outer": {"inner": "override"}} + result = deep_merge(m1, m2) + assert result["outer"]["inner"] == "override" + assert result["outer"]["keep"] == "value" + + def test_deep_merge_with_none_value(self): + m1 = {"a": 1} + m2 = {"b": None} + result = deep_merge(m1, m2) + assert result == {"a": 1, "b": None} + + def test_deep_merge_with_list_values(self): + m1 = {"a": [1, 2, 3]} + m2 = {"a": [4, 5]} + result = deep_merge(m1, m2) + assert result["a"] == [4, 5] + + def test_deep_merge_does_not_modify_original_maps(self): + m1 = {"a": {"x": 1}} + m2 = {"a": {"y": 2}} + m1_copy = {"a": {"x": 1}} + m2_copy = {"a": {"y": 2}} + deep_merge(m1, m2) + assert m1 == m1_copy + assert m2 == m2_copy + + def test_deep_merge_with_different_types(self): + m1 = {"a": 1} + m2 = {"a": "string"} + result = deep_merge(m1, m2) + assert result["a"] == "string" diff --git a/tests/test_parse_args.py b/tests/test_parse_args.py new file mode 100644 index 00000000..dcb79857 --- /dev/null +++ b/tests/test_parse_args.py @@ -0,0 +1,228 @@ +import pytest + +from ceph_devstack import parse_args + +from pathlib import Path + + +class TestParseArgs: + def test_parse_args_no_args(self): + args = parse_args([]) + assert args.command is None + assert args.verbose is False + assert args.dry_run is False + + def test_parse_args_verbose(self): + args = parse_args(["-v"]) + assert args.verbose is True + + def test_parse_args_dry_run(self): + args = parse_args(["--dry-run"]) + assert args.dry_run is True + + def test_parse_args_config_file(self): + args = parse_args(["-c", "/custom/path.toml"]) + assert args.config_file == Path("/custom/path.toml") + + def test_parse_args_config_dump(self): + args = parse_args(["config", "dump"]) + assert args.command == "config" + assert args.config_op == "dump" + + def test_parse_args_config_get(self): + args = parse_args(["config", "get", "data_dir"]) + assert args.command == "config" + assert args.config_op == "get" + assert args.name == "data_dir" + + def test_parse_args_config_set(self): + args = parse_args(["config", "set", "data_dir", "/custom/path"]) + assert args.command == "config" + assert args.config_op == "set" + assert args.name == "data_dir" + assert args.value == "/custom/path" + + def test_parse_args_doctor(self): + args = parse_args(["doctor"]) + assert args.command == "doctor" + + def test_parse_args_doctor_fix(self): + args = parse_args(["doctor", "--fix"]) + assert args.command == "doctor" + assert args.fix is True + + def test_parse_args_pull(self): + args = parse_args(["pull"]) + assert args.command == "pull" + + def test_parse_args_pull_with_images(self): + args = parse_args(["pull", "image1", "image2"]) + assert args.command == "pull" + assert "image1" in args.image + assert "image2" in args.image + + def test_parse_args_build(self): + args = parse_args(["build"]) + assert args.command == "build" + + def test_parse_args_build_with_images(self): + args = parse_args(["build", "image1"]) + assert args.command == "build" + assert "image1" in args.image + + def test_parse_args_create(self): + args = parse_args(["create"]) + assert args.command == "create" + + def test_parse_args_create_with_build(self): + args = parse_args(["create", "-b"]) + assert args.command == "create" + assert args.build is True + + def test_parse_args_create_with_wait(self): + args = parse_args(["create", "-w"]) + assert args.command == "create" + assert args.wait is True + + def test_parse_args_remove(self): + args = parse_args(["remove"]) + assert args.command == "remove" + + def test_parse_args_start(self): + args = parse_args(["start"]) + assert args.command == "start" + + def test_parse_args_stop(self): + args = parse_args(["stop"]) + assert args.command == "stop" + + def test_parse_args_watch(self): + args = parse_args(["watch"]) + assert args.command == "watch" + + def test_parse_args_wait(self): + args = parse_args(["wait", "teuthology"]) + assert args.command == "wait" + assert args.container == "teuthology" + + def test_parse_args_logs(self): + args = parse_args(["logs"]) + assert args.command == "logs" + + def test_parse_args_logs_with_run_name(self): + args = parse_args(["logs", "-r", "my-run"]) + assert args.command == "logs" + assert args.run_name == "my-run" + + def test_parse_args_logs_with_job_id(self): + args = parse_args(["logs", "-j", "42"]) + assert args.command == "logs" + assert args.job_id == "42" + + def test_parse_args_logs_with_locate(self): + args = parse_args(["logs", "--locate"]) + assert args.command == "logs" + assert args.locate is True + + def test_parse_args_logs_with_no_locate(self): + args = parse_args(["logs", "--no-locate"]) + assert args.command == "logs" + assert args.locate is False + + def test_parse_args_logs_with_all_options(self): + args = parse_args(["logs", "-r", "my-run", "-j", "42", "--locate"]) + assert args.command == "logs" + assert args.run_name == "my-run" + assert args.job_id == "42" + assert args.locate is True + + +class TestParseArgsDefaults: + def test_parse_args_default_verbose_false(self): + args = parse_args([]) + assert args.verbose is False + + def test_parse_args_default_dry_run_false(self): + args = parse_args([]) + assert args.dry_run is False + + def test_parse_args_default_config_path(self): + args = parse_args([]) + assert args.config_file == Path("~/.config/ceph-devstack/config.toml") + + def test_parse_args_default_command_is_none(self): + args = parse_args([]) + assert args.command is None + + def test_parse_args_default_config_op_is_none(self): + args = parse_args(["doctor"]) + assert not hasattr(args, "config_op") or args.config_op is None + + def test_parse_args_create_default_build_false(self): + args = parse_args(["create"]) + assert args.build is False + + def test_parse_args_create_default_wait_false(self): + args = parse_args(["create"]) + assert args.wait is False + + def test_parse_args_doctor_default_fix_false(self): + args = parse_args(["doctor"]) + assert args.fix is False + + def test_parse_args_logs_default_run_name_none(self): + args = parse_args(["logs"]) + assert args.run_name is None + + def test_parse_args_logs_default_job_id_none(self): + args = parse_args(["logs"]) + assert args.job_id is None + + +class TestParseArgsEdgeCases: + def test_parse_args_with_help(self): + with pytest.raises(SystemExit): + parse_args(["--help"]) + + def test_parse_args_with_subcommand_help(self): + with pytest.raises(SystemExit): + parse_args(["config", "--help"]) + + def test_parse_args_with_invalid_flag(self): + with pytest.raises(SystemExit): + parse_args(["--invalid-flag"]) + + def test_parse_args_config_dump_requires_no_args(self): + args = parse_args(["config", "dump"]) + assert args.config_op == "dump" + + def test_parse_args_config_get_requires_name(self): + args = parse_args(["config", "get", "test_name"]) + assert args.config_op == "get" + assert args.name == "test_name" + + def test_parse_args_config_set_requires_name_and_value(self): + args = parse_args(["config", "set", "test_name", "test_value"]) + assert args.config_op == "set" + assert args.name == "test_name" + assert args.value == "test_value" + + def test_parse_args_wait_requires_container_name(self): + args = parse_args(["wait", "teuthology"]) + assert args.command == "wait" + assert args.container == "teuthology" + + def test_parse_args_pull_accepts_multiple_images(self): + args = parse_args(["pull", "img1", "img2", "img3"]) + assert args.command == "pull" + assert len(args.image) == 3 + assert "img1" in args.image + assert "img2" in args.image + assert "img3" in args.image + + def test_parse_args_build_accepts_multiple_images(self): + args = parse_args(["build", "img1", "img2"]) + assert args.command == "build" + assert len(args.image) == 2 + assert "img1" in args.image + assert "img2" in args.image diff --git a/tests/test_requirements_core.py b/tests/test_requirements_core.py new file mode 100644 index 00000000..1d268fb6 --- /dev/null +++ b/tests/test_requirements_core.py @@ -0,0 +1,495 @@ +import asyncio +import pytest +from packaging.version import parse as parse_version +from unittest.mock import AsyncMock, patch + + +from ceph_devstack import config, requirements + + +@pytest.fixture(scope="class") +def cls(): + return requirements.Requirement + + +@pytest.fixture(scope="class") +def req(cls): + return cls() + + +class TestRequirement: + @pytest.fixture(scope="class") + def cls(self): + class TestReq(requirements.Requirement): + check_cmd = ["test", "command"] + + return TestReq + + async def test_check_returns_true_on_zero_rc(self, req): + mock_proc = AsyncMock() + mock_proc.wait = AsyncMock(return_value=0) + with patch.object(req.host, "arun", return_value=mock_proc): + result = await req.check() + assert result is True + + async def test_check_returns_false_on_nonzero_rc(self, req): + mock_proc = AsyncMock() + mock_proc.wait = AsyncMock(return_value=1) + with patch.object(req.host, "arun", return_value=mock_proc): + result = await req.check() + assert result is False + + async def test_evaluate_delegates_to_check(self, req): + with patch.object(req, "check", return_value=True) as mock_check: + result = await req.evaluate() + mock_check.assert_called_once() + assert result is True + + +class TestFixableRequirement: + @pytest.fixture(scope="class") + def cls(self): + class TestReq(requirements.FixableRequirement): + check_cmd = ["test", "-f", "/tmp/testfile"] + fix_cmd = ["touch", "/tmp/testfile"] + suggest_msg = "Test file missing" + + return TestReq + + async def test_evaluate_returns_true_when_check_passes(self, req): + with patch.object(req, "check", return_value=True): + result = await req.evaluate() + assert result is True + + async def test_evaluate_returns_false_when_check_fails(self, req): + config.setdefault("args", {}) + config["args"]["fix"] = False + with ( + patch.object(req, "check", return_value=False), + patch.object(req, "suggest") as mock_suggest, + ): + result = await req.evaluate() + assert result is False + mock_suggest.assert_called_once() + + async def test_evaluate_calls_fix_when_fix_flag_set(self, req): + config.setdefault("args", {}) + config["args"]["fix"] = True + with ( + patch.object(req, "check", return_value=False), + patch.object(req, "fix", return_value=True) as mock_fix, + ): + result = await req.evaluate() + assert result is True + mock_fix.assert_called_once() + + async def test_evaluate_returns_false_when_fix_fails(self, req): + config.setdefault("args", {}) + config["args"]["fix"] = True + with ( + patch.object(req, "check", return_value=False), + patch.object(req, "fix", return_value=False) as mock_fix, + ): + result = await req.evaluate() + assert result is False + mock_fix.assert_called_once() + + async def test_fix_requires_fix_cmd(self, req): + req.fix_cmd = [] + with pytest.raises(AssertionError): + await req.fix() + asyncio.run(req.fix()) + + +class TestLocalRequirement: + @pytest.fixture(scope="class") + def cls(self): + class TestReq(requirements.LocalRequirement): + check_cmd = ["test"] + + return TestReq + + def test_local_requirement_uses_local_host(self, req): + assert req.host == requirements.local_host + + +class TestPodmanVersionInit: + @pytest.fixture(scope="class") + def cls(self): + return requirements.PodmanVersion + + @pytest.fixture(scope="class") + def req(self, cls): + return cls("4.0.0") + + def test_podman_version_init_sets_version(self, req): + assert req.required_version == parse_version("4.0.0") + + def test_podman_version_init_sets_msg(self, cls): + req = cls("4.0.0", "Custom message") + assert req.msg == "Custom message" + + +class TestSysctlValueInit: + @pytest.fixture(scope="class") + def cls(self): + return requirements.SysctlValue + + @pytest.fixture(scope="class") + def req(self, cls): + return cls("fs.aio-max-nr", 2097152) + + def test_sysctl_value_init_sets_key(self, req): + assert req.key == "fs.aio-max-nr" + + def test_sysctl_value_init_sets_min_value(self, req): + assert req.min_value == 2097152 + + def test_sysctl_value_init_fix_cmd(self, req): + assert req.fix_cmd == ["sudo", "sysctl", "fs.aio-max-nr=2097152"] + + +class TestSELinuxBooleanInit: + @pytest.fixture(scope="class") + def cls(self): + return requirements.SELinuxBoolean + + @pytest.fixture(scope="class") + def req(self, cls): + return cls("test_bool") + + def test_selinux_boolean_init_sets_boolean_name(self, req): + assert req.boolean_name == "test_bool" + + def test_selinux_boolean_init_fix_cmd(self, req): + assert req.fix_cmd == ["sudo", "setsebool", "-P", "test_bool=true"] + + def test_selinux_boolean_init_suggest_msg(self, req): + assert "test_bool" in req.suggest_msg + + +class TestFuseOverlayfsPresence: + @pytest.fixture(scope="class") + def cls(self): + return requirements.FuseOverlayfsPresence + + def test_fuse_overlayfs_presence_check_cmd(self, req): + assert req.check_cmd == ["command", "-v", "fuse-overlayfs"] + + def test_fuse_overlayfs_presence_fix_cmd(self, req): + assert req.fix_cmd == ["sudo", "dnf", "install", "-y", "fuse-overlayfs"] + + def test_fuse_overlayfs_presence_suggest_msg(self, req): + assert req.suggest_msg == "Could not find fuse-overlayfs" + + +class TestCgroupV2Properties: + @pytest.fixture(scope="class") + def cls(self): + return requirements.CgroupV2 + + def test_cgroup_v2_suggest_msg(self, req): + assert req.suggest_msg == "cgroup v2 is not enabled" + + def test_cgroup_v2_fix_cmd(self, req): + assert req.fix_cmd == [ + "sudo", + "grubby", + "--update-kernel=ALL", + "--args='systemd.unified_cgroup_hierarchy=1'", + ] + + +class TestCgroupV2Check: + @pytest.fixture(scope="class") + def cls(self): + return requirements.CgroupV2 + + async def test_cgroup_v2_check_true(self, req): + mock_info = {"host": {"cgroupVersion": "v2"}} + with patch.object(req.host, "podman_info", return_value=mock_info): + result = await req.check() + assert result is True + + async def test_cgroup_v2_check_false(self, req): + mock_info = {"host": {"cgroupVersion": "v1"}} + with patch.object(req.host, "podman_info", return_value=mock_info): + result = await req.check() + assert result is False + + +class TestPodmanDNSPluginInit: + @pytest.fixture(scope="class") + def cls(self): + return requirements.PodmanDNSPlugin + + @pytest.fixture(scope="class", params=["centos", "ubuntu", "debian"]) + def os_type(self, request): + return request.param + + @pytest.fixture(scope="class") + def dns_plugin_path(self, os_type): + if os_type == "centos": + return "/usr/libexec/cni/dnsname" + elif os_type in ["ubuntu", "debian"]: + return "/usr/lib/cni/dnsname" + + def test_podman_dns_plugin_config(self, cls, os_type, dns_plugin_path): + with patch.object(cls.host, "os_type", return_value=os_type): + req = cls() + assert req.check_cmd == ["test", "-x", dns_plugin_path] + + +class TestAppArmorProfile: + @pytest.fixture(scope="class") + def cls(self): + return requirements.AppArmorProfile + + def test_apparmor_profile_check_cmd(self, req): + assert req.check_cmd == ["test", "-f", "/etc/apparmor.d/local/unix-chkpwd"] + + def test_apparmor_profile_fix_cmd(self, req): + assert req.fix_cmd[-1].endswith("&& systemctl reload apparmor") + + def test_apparmor_profile_suggest_msg(self, req): + assert req.suggest_msg == "Did not find required apparmor profile" + + +class TestFixableRequirementSuggestMsg: + @pytest.fixture(scope="class") + def cls(self): + class TestReq(requirements.FixableRequirement): + check_cmd = ["test"] + fix_cmd = ["fix"] + suggest_msg = "Please fix this" + + return TestReq + + def test_fixable_requirement_has_suggest_msg(self, req): + assert req.suggest_msg == "Please fix this" + + +class TestCheckRequirements: + async def test_check_requirements_returns_false_when_podman_not_platform(self): + with patch("ceph_devstack.requirements.PodmanPlatform") as MockPlatform: + mock_platform = AsyncMock() + mock_platform.evaluate = AsyncMock(return_value=False) + MockPlatform.return_value = mock_platform + result = await requirements.check_requirements() + assert result is False + mock_platform.evaluate.assert_called_once() + + async def test_check_requirements_returns_false_on_overlay_failure(self): + with ( + patch("ceph_devstack.requirements.PodmanPlatform") as MockPlatform, + patch("ceph_devstack.requirements.PodmanGraphDriver") as MockGraph, + ): + mock_platform = AsyncMock() + mock_platform.evaluate = AsyncMock(return_value=True) + MockPlatform.return_value = mock_platform + + mock_graph = AsyncMock() + mock_graph.evaluate = AsyncMock(return_value=False) + MockGraph.return_value = mock_graph + + result = await requirements.check_requirements() + assert result is False + + async def test_check_requirements_returns_true_when_all_pass(self): + with ( + patch("ceph_devstack.requirements.PodmanPlatform") as MockPlatform, + patch("ceph_devstack.requirements.PodmanGraphDriver") as MockGraph, + patch("ceph_devstack.requirements.PodmanVersion") as MockVersion, + patch("ceph_devstack.requirements.KernelVersionForOverlay") as MockKernel, + patch("ceph_devstack.requirements.CgroupV2") as MockCgroup, + patch( + "ceph_devstack.requirements.KernelVersionForCgroupV2" + ) as MockKernelCgroup, + patch("ceph_devstack.requirements.PodmanRuntime") as MockRuntime, + patch("ceph_devstack.requirements.host.selinux_enforcing") as mock_selinux, + patch("ceph_devstack.requirements.SysctlValue") as MockSysctl, + ): + mock_platform = AsyncMock() + mock_platform.evaluate = AsyncMock(return_value=True) + MockPlatform.return_value = mock_platform + + mock_graph = AsyncMock() + mock_graph.evaluate = AsyncMock(return_value=True) + MockGraph.return_value = mock_graph + + mock_version = AsyncMock() + mock_version.evaluate = AsyncMock(return_value=True) + MockVersion.return_value = mock_version + + mock_kernel = AsyncMock() + mock_kernel.evaluate = AsyncMock(return_value=True) + MockKernel.return_value = mock_kernel + + mock_cgroup = AsyncMock() + mock_cgroup.evaluate = AsyncMock(return_value=True) + MockCgroup.return_value = mock_cgroup + + mock_kernel_cgroup = AsyncMock() + mock_kernel_cgroup.evaluate = AsyncMock(return_value=True) + MockKernelCgroup.return_value = mock_kernel_cgroup + + mock_runtime = AsyncMock() + mock_runtime.evaluate = AsyncMock(return_value=True) + MockRuntime.return_value = mock_runtime + + mock_selinux.return_value = False + + mock_sysctl = AsyncMock() + mock_sysctl.evaluate = AsyncMock(return_value=True) + MockSysctl.return_value = mock_sysctl + + result = await requirements.check_requirements() + assert result is True + + async def test_check_requirements_returns_false_on_runtime_failure(self): + with ( + patch("ceph_devstack.requirements.PodmanPlatform") as MockPlatform, + patch("ceph_devstack.requirements.PodmanGraphDriver") as MockGraph, + patch("ceph_devstack.requirements.PodmanVersion") as MockVersion, + patch("ceph_devstack.requirements.KernelVersionForOverlay") as MockKernel, + patch("ceph_devstack.requirements.CgroupV2") as MockCgroup, + patch( + "ceph_devstack.requirements.KernelVersionForCgroupV2" + ) as MockKernelCgroup, + patch("ceph_devstack.requirements.PodmanRuntime") as MockRuntime, + patch("ceph_devstack.requirements.host.selinux_enforcing") as mock_selinux, + ): + mock_platform = AsyncMock() + mock_platform.evaluate = AsyncMock(return_value=True) + MockPlatform.return_value = mock_platform + + mock_graph = AsyncMock() + mock_graph.evaluate = AsyncMock(return_value=True) + MockGraph.return_value = mock_graph + + mock_version = AsyncMock() + mock_version.evaluate = AsyncMock(return_value=True) + MockVersion.return_value = mock_version + + mock_kernel = AsyncMock() + mock_kernel.evaluate = AsyncMock(return_value=True) + MockKernel.return_value = mock_kernel + + mock_cgroup = AsyncMock() + mock_cgroup.evaluate = AsyncMock(return_value=True) + MockCgroup.return_value = mock_cgroup + + mock_kernel_cgroup = AsyncMock() + mock_kernel_cgroup.evaluate = AsyncMock(return_value=True) + MockKernelCgroup.return_value = mock_kernel_cgroup + + mock_runtime = AsyncMock() + mock_runtime.evaluate = AsyncMock(return_value=False) + MockRuntime.return_value = mock_runtime + + mock_selinux.return_value = False + + result = await requirements.check_requirements() + assert result is False + + async def test_check_requirements_returns_false_on_selinux_bool_failure(self): + with ( + patch("ceph_devstack.requirements.PodmanPlatform") as MockPlatform, + patch("ceph_devstack.requirements.PodmanGraphDriver") as MockGraph, + patch("ceph_devstack.requirements.KernelVersionForOverlay") as MockKernel, + patch("ceph_devstack.requirements.CgroupV2") as MockCgroup, + patch( + "ceph_devstack.requirements.KernelVersionForCgroupV2" + ) as MockKernelCgroup, + patch("ceph_devstack.requirements.PodmanRuntime") as MockRuntime, + patch("ceph_devstack.requirements.host.selinux_enforcing") as mock_selinux, + patch("ceph_devstack.requirements.SELinuxBoolean") as MockSELinuxBoolean, + ): + mock_platform = AsyncMock() + mock_platform.evaluate = AsyncMock(return_value=True) + MockPlatform.return_value = mock_platform + + mock_graph = AsyncMock() + mock_graph.evaluate = AsyncMock(return_value=True) + MockGraph.return_value = mock_graph + + with patch("ceph_devstack.requirements.PodmanVersion") as MockVersion: + mock_version = AsyncMock() + mock_version.evaluate = AsyncMock(return_value=True) + MockVersion.return_value = mock_version + + mock_kernel = AsyncMock() + mock_kernel.evaluate = AsyncMock(return_value=True) + MockKernel.return_value = mock_kernel + + mock_cgroup = AsyncMock() + mock_cgroup.evaluate = AsyncMock(return_value=True) + MockCgroup.return_value = mock_cgroup + + mock_kernel_cgroup = AsyncMock() + mock_kernel_cgroup.evaluate = AsyncMock(return_value=True) + MockKernelCgroup.return_value = mock_kernel_cgroup + + mock_runtime = AsyncMock() + mock_runtime.evaluate = AsyncMock(return_value=True) + MockRuntime.return_value = mock_runtime + + mock_selinux.return_value = True + + mock_sel = AsyncMock() + mock_sel.evaluate = AsyncMock(return_value=False) + MockSELinuxBoolean.return_value = mock_sel + + result = await requirements.check_requirements() + assert result is False + + async def test_check_requirements_returns_false_on_sysctl_failure(self): + with ( + patch("ceph_devstack.requirements.PodmanPlatform") as MockPlatform, + patch("ceph_devstack.requirements.PodmanGraphDriver") as MockGraph, + patch("ceph_devstack.requirements.PodmanVersion") as MockVersion, + patch("ceph_devstack.requirements.KernelVersionForOverlay") as MockKernel, + patch("ceph_devstack.requirements.CgroupV2") as MockCgroup, + patch( + "ceph_devstack.requirements.KernelVersionForCgroupV2" + ) as MockKernelCgroup, + patch("ceph_devstack.requirements.PodmanRuntime") as MockRuntime, + patch("ceph_devstack.requirements.host.selinux_enforcing") as mock_selinux, + patch("ceph_devstack.requirements.SysctlValue") as MockSysctl, + ): + mock_platform = AsyncMock() + mock_platform.evaluate = AsyncMock(return_value=True) + MockPlatform.return_value = mock_platform + + mock_graph = AsyncMock() + mock_graph.evaluate = AsyncMock(return_value=True) + MockGraph.return_value = mock_graph + + mock_version = AsyncMock() + mock_version.evaluate = AsyncMock(return_value=True) + MockVersion.return_value = mock_version + + mock_kernel = AsyncMock() + mock_kernel.evaluate = AsyncMock(return_value=True) + MockKernel.return_value = mock_kernel + + mock_cgroup = AsyncMock() + mock_cgroup.evaluate = AsyncMock(return_value=True) + MockCgroup.return_value = mock_cgroup + + mock_kernel_cgroup = AsyncMock() + mock_kernel_cgroup.evaluate = AsyncMock(return_value=True) + MockKernelCgroup.return_value = mock_kernel_cgroup + + mock_runtime = AsyncMock() + mock_runtime.evaluate = AsyncMock(return_value=True) + MockRuntime.return_value = mock_runtime + + mock_selinux.return_value = False + + mock_sysctl = AsyncMock() + mock_sysctl.evaluate = AsyncMock(return_value=False) + MockSysctl.return_value = mock_sysctl + + result = await requirements.check_requirements() + assert result is False From 7c9fafc62663e7f34ce817edf806a5f40d8fdcb5 Mon Sep 17 00:00:00 2001 From: Zack Cerza Date: Tue, 21 Apr 2026 17:33:05 -0600 Subject: [PATCH 8/8] host: Fix SELinux boolean logic Signed-off-by: Zack Cerza --- ceph_devstack/host.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ceph_devstack/host.py b/ceph_devstack/host.py index 6f50f8fd..9d5ececa 100644 --- a/ceph_devstack/host.py +++ b/ceph_devstack/host.py @@ -100,7 +100,7 @@ async def check_selinux_bool(self, name: str): proc = await host.arun(["getsebool", name]) assert proc.stdout is not None out = await proc.stdout.read() - return out.decode().strip() != f"{name} --> on" + return out.decode().strip() == f"{name} --> on" async def get_sysctl_value(self, name: str) -> int: proc = await host.arun(["sysctl", "-b", name])