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 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/.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 \ 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 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" + ) 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__":