From b678d0d8c1cff2b854fe1a1dc588048e6948a863 Mon Sep 17 00:00:00 2001 From: Florent Carli Date: Thu, 6 Aug 2026 12:40:05 +0200 Subject: [PATCH 1/5] ci: give SonarCloud a version to compare against The New Code definition is "previous version" and no analysis ever ran with one, so the period falls back to the first analysis ever: 3562 new lines for 3334 lines of code, the whole code base. That is what turned the main gate red on new_coverage and new_security_rating as soon as #94 published coverage. Pull requests were never affected, their gate is computed on their own diff. Pass the version from pyproject.toml, which stays the single source of truth, so each release gives the period a real boundary. Signed-off-by: Florent Carli --- .cqfd/docker/python-sonar.sh | 4 ---- .github/workflows/ci.yml | 21 +++++++++++++++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) delete mode 100755 .cqfd/docker/python-sonar.sh diff --git a/.cqfd/docker/python-sonar.sh b/.cqfd/docker/python-sonar.sh deleted file mode 100755 index c1a1004..0000000 --- a/.cqfd/docker/python-sonar.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/bash -set -e -pylint --exit-zero "$@" >pylint-report.txt -/opt/sonar-scanner/bin/sonar-scanner -Dsonar.analysis.mode=preview -Dsonar.report.export.path=sonar-report.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cb0dd51..8b987e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -99,6 +99,25 @@ jobs: htmlcov/ if-no-files-found: ignore + - name: Read the project version + id: version + # The SonarCloud project compares against the previous version to + # decide what counts as new code. Every analysis so far ran without + # a version, so there was no boundary and the period fell back to + # the first analysis ever: the whole code base counted as new, and + # publishing coverage turned the main branch gate red on 2000 lines + # of pre-existing code. Feeding the scanner the version from + # pyproject.toml, the single source of truth, gives the period a + # real boundary at each release. + run: | + python - <<'PY' >> "$GITHUB_OUTPUT" + import pathlib + import tomllib + + pyproject = tomllib.loads(pathlib.Path("pyproject.toml").read_text()) + print("version=" + pyproject["project"]["version"]) + PY + # 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 @@ -108,6 +127,8 @@ jobs: uses: SonarSource/sonarqube-scan-action@22918119ff8e1ca75a623e15c8296b6ea4fbe28f # v8.2.1 env: SONAR_HOST_URL: https://sonarcloud.io + with: + args: -Dsonar.projectVersion=${{ steps.version.outputs.version }} - name: Install documentation dependencies # Editable too, so this step adds the docs extra instead of From cfb6db9af0c1897a52650b05df05ea526d4790b8 Mon Sep 17 00:00:00 2001 From: Florent Carli Date: Thu, 6 Aug 2026 12:40:14 +0200 Subject: [PATCH 2/5] api: bind the debug server to the loopback The application authenticates nobody. In production the vmmgrapi role of seapath/ansible serves it with gunicorn behind an nginx that carries the TLS, the basic auth and the ACL, and main() is not that path: gunicorn imports wsgi:app. On 0.0.0.0 this debug entry point published every route, /start and /stop included, in clear text on every interface. Fixes python:S8392. Signed-off-by: Florent Carli --- vm_manager/vm_manager_api.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/vm_manager/vm_manager_api.py b/vm_manager/vm_manager_api.py index 27d14c1..629ee7f 100755 --- a/vm_manager/vm_manager_api.py +++ b/vm_manager/vm_manager_api.py @@ -44,7 +44,13 @@ def start_vm(guest): def main(): - app.run(host="0.0.0.0") + # Loopback on purpose. In production this module is imported by the + # wsgi.py of the vmmgrapi Ansible role and served by gunicorn on a + # unix socket, behind an nginx that carries the TLS, the basic auth + # and the ACL. This entry point is for local debugging only, and + # listening on every interface would publish every route, in clear + # text and unauthenticated, around all of that. + app.run(host="127.0.0.1") if __name__ == "__main__": From 65326e4f96323544cab5cadb3d8f43bcc0426db4 Mon Sep 17 00:00:00 2001 From: Florent Carli Date: Thu, 6 Aug 2026 12:40:14 +0200 Subject: [PATCH 3/5] cqfd: pin and restrict the sphinx-argparse install A version so a rebuild cannot pick up a new release on its own, and --only-binary :all: so no dependency runs a setup script at install time, as commit 9217d68 did for the workflow steps. Fixes docker:S8544 and docker:S8541. 0.6.0 is what the unpinned line already resolved to on the python3.10 of ubuntu 22.04, so the image does not change. Signed-off-by: Florent Carli --- .cqfd/docker/Dockerfile | 23 ++++++----------------- 1 file changed, 6 insertions(+), 17 deletions(-) diff --git a/.cqfd/docker/Dockerfile b/.cqfd/docker/Dockerfile index b81761b..4d6c947 100644 --- a/.cqfd/docker/Dockerfile +++ b/.cqfd/docker/Dockerfile @@ -11,21 +11,10 @@ RUN set -x \ python3-setuptools \ python3-sphinx \ python3-pip \ - openjdk-8-jre-headless \ - unzip \ - wget \ && rm -rf /var/lib/apt/lists/ \ - && pip3 install --no-cache-dir sphinx-argparse - -ARG sonar_version=4.7.0.2747 -ARG sonar_repo=https://binaries.sonarsource.com/Distribution/sonar-scanner-cli -RUN set -x \ - && wget -O /tmp/sonar-scanner.zip \ - "${sonar_repo}/sonar-scanner-cli-${sonar_version}.zip" \ - && cd /opt \ - && unzip /tmp/sonar-scanner.zip \ - && rm -f sonar-scanner.zip -RUN ln -s "/opt/sonar-scanner-${sonar_version}" /opt/sonar-scanner -RUN echo 'sonar.host.url=http://j1.sfl.team:9000/' \ - > /opt/sonar-scanner/conf/sonar-scanner.properties -COPY python-sonar.sh /usr/bin/python-sonar.sh + # Pinned so a rebuild 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. 0.6.0 is what the unpinned line already + # resolved to on the python3.10 of ubuntu 22.04. + && pip3 install --no-cache-dir --only-binary :all: \ + sphinx-argparse==0.6.0 From 19fd11566f20d596f5dfa862cf8188716b1d35ee Mon Sep 17 00:00:00 2001 From: Florent Carli Date: Thu, 6 Aug 2026 12:40:20 +0200 Subject: [PATCH 4/5] cqfd: drop the scanner of the internal SonarQube The sonar flavor aimed sonar-scanner 4.7.0 at http://j1.sfl.team:9000/, an SFL instance this repository no longer reports to: commit 1dd9bdf moved the analysis to SonarCloud, run from the workflow. Removing it also takes openjdk-8-jre-headless, unzip and wget out of the image, which nothing else uses, and fixes docker:S6506, the download following redirects unchecked, and docker:S5332, the clear-text URL. Signed-off-by: Florent Carli --- .cqfdrc | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/.cqfdrc b/.cqfdrc index 1108732..717ae32 100644 --- a/.cqfdrc +++ b/.cqfdrc @@ -2,17 +2,11 @@ org='rte' name='vm_manager' -flavors='check sonar check_format format flake docs' +flavors='check check_format format flake docs' [build] command='/usr/bin/pip install --root-user-action=ignore --prefix=. .' -[sonar] -command='/usr/bin/python-sonar.sh \ - pacemaker_helper \ - rbd_helper \ - vm_manager' - [check] command='pylint \ pacemaker_helper \ From 34c63d1e80dcb892ad6ab23e4e63a72534d8ab38 Mon Sep 17 00:00:00 2001 From: Florent Carli Date: Thu, 6 Aug 2026 12:44:51 +0200 Subject: [PATCH 5/5] tests: cover the REST API vm_manager_api.py was at 0%, so the single line the previous commit changed failed this pull request's own gate: 1 new line to cover, 0 covered. Cover the four routes, both branches of execfunc and main(), with every vm_manager call replaced. The module reaches 94%, the rest being the __main__ guard. The main() test asserts the bind address. Signed-off-by: Florent Carli --- tests/test_vm_manager_api.py | 91 ++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 tests/test_vm_manager_api.py diff --git a/tests/test_vm_manager_api.py b/tests/test_vm_manager_api.py new file mode 100644 index 0000000..549c1c2 --- /dev/null +++ b/tests/test_vm_manager_api.py @@ -0,0 +1,91 @@ +# Copyright (C) 2026, RTE (http://www.rte-france.com) +# SPDX-License-Identifier: Apache-2.0 + +""" +Unit tests for the Flask REST API. + +Every vm_manager entry point the routes call is replaced here, so nothing +touches libvirt, Ceph or Pacemaker. + +In production this module is not run directly: the vmmgrapi role of +seapath/ansible serves `app` with gunicorn on a unix socket, behind an +nginx that carries the TLS, the authentication and the ACL. main() is a +debug entry point, and since the application authenticates nobody on its +own, the address it binds to is worth pinning down in a test. +""" + +import pytest + +from vm_manager import vm_manager_api + + +@pytest.fixture +def client(): + """Return a test client for the API.""" + return vm_manager_api.app.test_client() + + +def test_main_binds_the_loopback(monkeypatch): + calls = [] + monkeypatch.setattr( + vm_manager_api.app, "run", lambda **kwargs: calls.append(kwargs) + ) + + vm_manager_api.main() + + assert calls == [{"host": "127.0.0.1"}] + + +def test_list_vms(client, monkeypatch): + monkeypatch.setattr(vm_manager_api.v, "list_vms", lambda: ["vm1", "vm2"]) + + response = client.get("/") + + assert response.status_code == 200 + assert response.get_json() == ["vm1", "vm2"] + + +def test_status(client, monkeypatch): + monkeypatch.setattr( + vm_manager_api.v, "status", lambda guest: f"{guest} is Running" + ) + + response = client.get("/status/guest0") + + assert response.status_code == 200 + assert response.get_data(as_text=True) == "guest0 is Running" + + +def test_stop(client, monkeypatch): + monkeypatch.setattr( + vm_manager_api.v, "stop", lambda guest: f"{guest} stopped" + ) + + response = client.get("/stop/guest0") + + assert response.status_code == 200 + assert response.get_data(as_text=True) == "guest0 stopped" + + +def test_start_reports_a_silent_success(client, monkeypatch): + """A backend returning nothing is a success, not an empty answer.""" + monkeypatch.setattr(vm_manager_api.v, "start", lambda guest: None) + + response = client.get("/start/guest0") + + assert response.status_code == 200 + assert "should be OK" in response.get_data(as_text=True) + + +def test_start_reports_the_backend_error(client, monkeypatch): + def raise_error(guest): + raise RuntimeError(f"no such VM: {guest}") + + monkeypatch.setattr(vm_manager_api.v, "start", raise_error) + + response = client.get("/start/guest0") + + assert response.status_code == 500 + assert ( + response.get_data(as_text=True) == "RuntimeError: no such VM: guest0" + )