Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .flake8
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ exclude = build
per-file-ignores =
__init__.py:F401
vm_manager_cmd.py:F841
conftest.py:E402
66 changes: 63 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,15 @@ jobs:

test:
runs-on: ubuntu-24.04
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
steps:
- uses: actions/checkout@v4
with:
# SonarCloud needs the full history to blame each line. Without
# it, every line of a file the pull request touches counts as new
# code, which drags pre-existing issues into the quality gate.
fetch-depth: 0

- uses: actions/setup-python@v5
with:
Expand All @@ -51,13 +58,66 @@ jobs:
sudo usermod -aG libvirt "$USER"

- name: Install package with test deps
run: pip install ".[test]"
# Editable on purpose: coverage instruments the vm_manager package in
# the checkout, so the tests must import it from there. A regular
# install copies it to site-packages, the tests import that copy, and
# coverage reports 0% on every module.
#
# Constrained so a run does not pick up a new release on its own,
# and restricted to wheels so no dependency gets to run a setup
# script during the install. libvirt-python is the one exception:
# it publishes no wheel and compiles against the system libvirt
# headers, which is why libvirt-dev and pkg-config are installed
# above. Naming it keeps the exception to that single package
# instead of reopening source builds for the whole graph.
run: |
pip install -e ".[test]" -c requirements-ci.txt \
--only-binary :all: --no-binary libvirt-python

- name: Run tests
run: sg libvirt -c "pytest tests/ -v --tb=short --ignore=tests/test_vm_manager_cluster.py --ignore=tests/test_vm_manager_cmd_cluster.py"
run: |
sg libvirt -c "pytest tests/ -v --tb=short \
--cov --cov-report=term-missing --cov-report=xml --cov-report=html \
--ignore=tests/test_vm_manager_cluster.py \
--ignore=tests/test_vm_manager_cmd_cluster.py"

- name: Add coverage to job summary
if: always()
run: |
if [ -f .coverage ]; then
echo '## Coverage' >> "$GITHUB_STEP_SUMMARY"
python -m coverage report --format=markdown >> "$GITHUB_STEP_SUMMARY"
fi

- name: Upload coverage report
if: always()
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: |
coverage.xml
htmlcov/
if-no-files-found: ignore

# Automatic Analysis must stay off in the SonarCloud project
# settings, otherwise SonarCloud rejects this analysis. Skipped when
# SONAR_TOKEN is unavailable, which is the case for pull requests
# opened from a fork.
- name: SonarCloud analysis
if: env.SONAR_TOKEN != ''
uses: SonarSource/sonarqube-scan-action@22918119ff8e1ca75a623e15c8296b6ea4fbe28f # v8.2.1
env:
SONAR_HOST_URL: https://sonarcloud.io

- name: Install documentation dependencies
run: pip install ".[docs]"
# Editable too, so this step adds the docs extra instead of
# replacing the editable install made above by a copy. Sphinx and
# sphinx-argparse both ship wheels, the exception below is still
# needed because resolving the package pulls its runtime
# dependencies, libvirt-python included.
run: |
pip install -e ".[docs]" -c requirements-ci.txt \
--only-binary :all: --no-binary libvirt-python

- name: Build documentation
run: sphinx-build -b html docs/ docs/_build/html
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ __pycache__/
# Ignore generated documentation
/docs/_build/

# Ignore coverage files
/.coverage
/coverage.xml
/htmlcov/

# Ignore sonar files
/.scannerwork/
/.sonar/
Expand Down
58 changes: 55 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,28 @@ cqfd -b flake # flake8
cqfd -b check # pylint
```

## SonarCloud

Project `seapath_vm_manager` in the `seapath` organisation, public:
https://sonarcloud.io/summary/overall?id=seapath_vm_manager

The analysis runs from the `test` job of `.github/workflows/ci.yml`, after
the tests, so that `coverage.xml` exists and can be imported. Scope and
report paths are in `sonar-project.properties`. Two things are easy to get
wrong here:

- **Automatic Analysis must stay disabled** in the project settings.
SonarCloud refuses a CI analysis while it is on, and Automatic Analysis
can never report coverage because it does not run the tests. The project
ran that way until 2026-08 and consequently had no coverage data at all.
- **`fetch-depth: 0` on the checkout is required.** Without full history
SonarCloud has no blame data, so every line of a file a pull request
touches counts as new code and pre-existing issues fail the new-code
quality gate.

Analysis needs the `SONAR_TOKEN` repository secret. The step is skipped
when it is absent, which is what happens for pull requests from forks.

## Documentation

```bash
Expand All @@ -58,15 +80,45 @@ sphinx-argparse requires `get_parser()` functions in both CLI modules.

## Tests

Tests are integration scripts requiring a real Ceph/Pacemaker cluster. Run individually:
The pytest suite lives in `tests/`:

```bash
pip install .[test]

# Everything (needs a real Ceph/Pacemaker cluster)
pytest tests/

# What CI runs: no cluster needed
pytest tests/ \
--ignore=tests/test_vm_manager_cluster.py \
--ignore=tests/test_vm_manager_cmd_cluster.py

# With coverage (branch coverage is on by default)
pytest tests/ --cov --cov-report=term-missing --cov-report=html
```

`tests/conftest.py` calls `install_ceph_stubs()` from `tests/ceph_stubs.py`
**before** importing vm_manager. vm_manager picks its backend at import time
(`__init__.py`), so without the stubs `cluster_mode` is False on any machine
without Ceph and all cluster-side tests are skipped, even the ones that need
no cluster. The stubs raise on use, so a test that reaches real Ceph code
fails loudly. Anything genuinely needing a cluster belongs in
`test_vm_manager_cluster.py` or `test_vm_manager_cmd_cluster.py`, which CI
ignores.

Coverage config is in `pyproject.toml` (`[tool.coverage.run]`). There is
deliberately no `fail_under` yet: the project is establishing a baseline
towards the OpenSSF gold criteria (90% statement, 80% branch).

Older standalone integration scripts, run by hand against a real cluster,
live in `vm_manager/helpers/tests/pacemaker/` and
`vm_manager/helpers/tests/rbd_manager/`:

```bash
python3 -m vm_manager.helpers.tests.rbd_manager.clone_rbd
python3 -m vm_manager.helpers.tests.pacemaker.add_vm
```

Test scripts are in `vm_manager/helpers/tests/pacemaker/` and `vm_manager/helpers/tests/rbd_manager/`.

## Architecture

**Entry points** (defined in `pyproject.toml [project.scripts]`):
Expand Down
28 changes: 25 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,35 @@ pytest tests/

### Run only standalone tests (no cluster required)

The cluster tests (`test_vm_manager_cluster.py`) require a running Pacemaker/Ceph
cluster. To run only the standalone libvirt tests, exclude that file:
The cluster tests (`test_vm_manager_cluster.py` and
`test_vm_manager_cmd_cluster.py`) require a running Pacemaker/Ceph cluster.
To run only the standalone tests, exclude those files:

```bash
pytest tests/ --ignore=tests/test_vm_manager_cluster.py
pytest tests/ \
--ignore=tests/test_vm_manager_cluster.py \
--ignore=tests/test_vm_manager_cmd_cluster.py
```

This is what the CI workflow runs. Tests that exercise the cluster code
paths without touching a cluster (argparse, XML building, command
construction) still run here: `tests/conftest.py` registers stub `rados`
and `rbd` modules when the real Ceph bindings are absent, so vm_manager
starts in cluster mode. The stubs raise as soon as they are used, so a test
that reaches real Ceph code fails rather than passing against a fake. See
`tests/ceph_stubs.py`.

### Measure coverage

```bash
pytest tests/ --cov --cov-report=term-missing --cov-report=html
```

Branch coverage is enabled by default (see `[tool.coverage.run]` in
`pyproject.toml`). The HTML report lands in `htmlcov/`. The CI workflow
publishes `coverage.xml` and `htmlcov/` as a build artifact and prints a
summary table in the job summary.

## Documentation

The HTML documentation is generated with Sphinx.
Expand Down
17 changes: 16 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,24 @@ dependencies = [
readme = "README.md"

[project.optional-dependencies]
test = ["pytest>=7.0"]
test = ["pytest>=7.0", "pytest-cov>=4.0", "coverage[toml]>=7.0"]
docs = ["sphinx>=4.0", "sphinx-argparse"]

[tool.coverage.run]
branch = true
source = ["vm_manager"]
omit = [
# Standalone integration scripts shipped with the package, driven by
# hand against a real cluster. They are tests, not product code.
"vm_manager/helpers/tests/*",
]

[tool.coverage.report]
show_missing = true
# No exclusions and no fail_under yet: this is a baseline measurement, and
# the OpenSSF gold criteria (90% statement, 80% branch) need an honest
# starting number before thresholds are worth enforcing.

[tool.setuptools]
packages = ["vm_manager", "vm_manager.helpers", "vm_manager.helpers.tests.pacemaker", "vm_manager.helpers.tests.rbd_manager"]

Expand Down
22 changes: 22 additions & 0 deletions requirements-ci.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Copyright (C) 2026, RTE (http://www.rte-france.com)
# SPDX-License-Identifier: Apache-2.0
#
# Pinned toolchain for the CI jobs, so a run is reproducible and does not
# silently pick up a new release between two builds. Used as a pip
# constraints file, transitive dependencies are still resolved normally.
# The versions below are the ones the workflow already resolves on the
# Python 3.12 it installs, so pinning them changes nothing but the drift.

# Runtime dependencies, from [project.dependencies].
flask==3.1.3
Flask-WTF==1.3.0
libvirt-python==12.5.0

# The test extra.
pytest==9.1.1
pytest-cov==7.1.0
coverage==7.15.3

# The docs extra.
sphinx==9.1.0
sphinx-argparse==0.6.0
20 changes: 20 additions & 0 deletions sonar-project.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Copyright (C) 2026, RTE (http://www.rte-france.com)
# SPDX-License-Identifier: Apache-2.0

sonar.projectKey=seapath_vm_manager
sonar.organization=seapath

# Keep the whole repository in scope, as Automatic Analysis did, so the
# workflow, Dockerfile and XML analysers keep reporting. Only build output
# is excluded.
sonar.sources=.
sonar.exclusions=tests/**, docs/_build/**, htmlcov/**
sonar.tests=tests

sonar.python.version=3.8, 3.9, 3.10, 3.11, 3.12

# Written by the "Run tests" step of .github/workflows/ci.yml. Automatic
# Analysis could never provide this: it does not run the build or the
# tests, which is why SonarCloud has never had coverage data for this
# project.
sonar.python.coverage.reportPaths=coverage.xml
91 changes: 91 additions & 0 deletions tests/ceph_stubs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# Copyright (C) 2026, RTE (http://www.rte-france.com)
# SPDX-License-Identifier: Apache-2.0

"""
Import-time stubs for the Ceph Python bindings.

vm_manager picks its backend when it is first imported (see
vm_manager/__init__.py): if ``rados`` and ``rbd`` are importable it exposes
the cluster API, otherwise it falls back to the libvirt-only API. Those
bindings ship with Ceph itself and are not installable from PyPI, so on a
plain CI runner ``cluster_mode`` is False and every cluster-side test is
skipped, including the ones that only exercise argparse or pure helper
functions and need no cluster at all.

Installing these stubs before vm_manager is imported makes ``cluster_mode``
True so those tests run. The stubs only satisfy the import: instantiating
one raises, so a test that reaches real Ceph code fails loudly instead of
quietly passing against a fake.

On a machine with Ceph installed the genuine bindings are found and nothing
is stubbed.
"""

import sys
import types


class _CephStub:
"""Placeholder for a Ceph binding class, unusable on purpose."""

def __init__(self, *args, **kwargs):
raise RuntimeError(
"{} is a test stub: this test reached real Ceph code, which "
"needs a live cluster. Mock the RbdManager method under test, "
"or move the test to tests/test_vm_manager_cluster.py.".format(
type(self).__name__
)
)


class Rados(_CephStub):
"""Stub for :class:`rados.Rados`."""


class RBD(_CephStub):
"""Stub for :class:`rbd.RBD`."""


class Group(_CephStub):
"""Stub for :class:`rbd.Group`."""


class Image(_CephStub):
"""Stub for :class:`rbd.Image`."""


def _make_module(name, attrs):
"""Build a stub module exposing ``attrs``.

:param name: the module name to register in :data:`sys.modules`
:param attrs: a dict of attribute name to value
:return: the newly created module
"""
module = types.ModuleType(name)
module.__doc__ = "Test stub for the Ceph '{}' bindings.".format(name)
for attr_name, value in attrs.items():
setattr(module, attr_name, value)
return module


def install_ceph_stubs():
"""Register stub ``rados`` and ``rbd`` modules if the real ones are absent.

Must be called before vm_manager is imported for the first time.

:return: True if stubs were installed, False if the real Ceph bindings
are available and were left alone
"""
try:
import rados # noqa: F401
import rbd # noqa: F401
except ModuleNotFoundError:
pass
else:
return False

sys.modules["rados"] = _make_module("rados", {"Rados": Rados})
sys.modules["rbd"] = _make_module(
"rbd", {"RBD": RBD, "Group": Group, "Image": Image}
)
return True
Loading
Loading