From 02797c891f1923b1ccbed223f872141f6d743230 Mon Sep 17 00:00:00 2001 From: Juan Denis Date: Tue, 10 Feb 2026 08:25:54 -0500 Subject: [PATCH 1/7] Add system utils CI workflow and integration tests Introduce a new GitHub Actions workflow (.github/workflows/test-system-utils.yml) that runs unit tests, distro-based integration tests in container images (ubuntu, debian, fedora, rocky) to validate package manager and systemctl detection, and an audit job that greps migrated service files for raw subprocess patterns. Add backend/tests/test_utils_system_integration.py: Linux-only integration smoke tests that directly import backend/app/utils/system.py (avoiding Flask deps) to verify PackageManager detection, command availability, and basic ServiceControl behavior. Designed for CI verification of system utilities across real distros. --- .github/workflows/test-system-utils.yml | 189 ++++++++++++++++++ .../tests/test_utils_system_integration.py | 137 +++++++++++++ 2 files changed, 326 insertions(+) create mode 100644 .github/workflows/test-system-utils.yml create mode 100644 backend/tests/test_utils_system_integration.py diff --git a/.github/workflows/test-system-utils.yml b/.github/workflows/test-system-utils.yml new file mode 100644 index 00000000..956685e1 --- /dev/null +++ b/.github/workflows/test-system-utils.yml @@ -0,0 +1,189 @@ +name: Test System Utilities + +on: + push: + branches: [dev] + paths: + - 'backend/app/utils/system.py' + - 'backend/app/services/**' + - 'backend/tests/test_utils_system*.py' + - '.github/workflows/test-system-utils.yml' + pull_request: + branches: [main, dev] + paths: + - 'backend/app/utils/system.py' + - 'backend/app/services/**' + - 'backend/tests/test_utils_system*.py' + +jobs: + # ────────────────────────────────────────────────────────────────── + # Job 1: Mocked unit tests — fast, validates all logic + # ────────────────────────────────────────────────────────────────── + unit-tests: + name: Unit Tests + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install test deps + run: pip install pytest + + - name: Run unit tests + working-directory: backend + run: python -m pytest tests/test_utils_system.py -v + + # ────────────────────────────────────────────────────────────────── + # Job 2: Integration tests on real distros (no mocks) + # ────────────────────────────────────────────────────────────────── + integration-tests: + name: Integration (${{ matrix.distro }}) + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + include: + - distro: ubuntu + image: ubuntu:22.04 + expected_manager: apt + - distro: debian + image: debian:12 + expected_manager: apt + - distro: fedora + image: fedora:40 + expected_manager: dnf + - distro: rocky + image: rockylinux:9 + expected_manager: dnf + + container: + image: ${{ matrix.image }} + + steps: + - uses: actions/checkout@v4 + + - name: Install Python & pytest + run: | + if command -v apt-get &>/dev/null; then + apt-get update -qq && apt-get install -y -qq python3 python3-pip >/dev/null 2>&1 + elif command -v dnf &>/dev/null; then + dnf install -y -q python3 python3-pip >/dev/null 2>&1 + elif command -v yum &>/dev/null; then + yum install -y -q python3 python3-pip >/dev/null 2>&1 + fi + pip3 install --break-system-packages pytest 2>/dev/null || pip3 install pytest + + - name: Run integration smoke tests + working-directory: backend + env: + EXPECTED_MANAGER: ${{ matrix.expected_manager }} + run: python3 -m pytest tests/test_utils_system_integration.py -v + + - name: Verify expected package manager + working-directory: backend + env: + EXPECTED_MANAGER: ${{ matrix.expected_manager }} + run: | + python3 -c " + import importlib, os, sys, types + + # Direct import to avoid Flask deps + mod_path = os.path.join('app', 'utils', 'system.py') + spec = importlib.util.spec_from_file_location('app.utils.system', mod_path) + mod = importlib.util.module_from_spec(spec) + sys.modules['app'] = types.ModuleType('app') + utils = types.ModuleType('app.utils') + sys.modules['app.utils'] = utils + sys.modules['app'].utils = utils + sys.modules['app.utils.system'] = mod + sys.modules['app.utils'].system = mod + spec.loader.exec_module(mod) + + expected = os.environ['EXPECTED_MANAGER'] + detected = mod.PackageManager.detect() + print(f'Distro detected: {detected} (expected: {expected})') + assert detected == expected, f'FAIL: expected {expected}, got {detected}' + print('PASS') + " + + # ────────────────────────────────────────────────────────────────── + # Job 3: Audit — grep for raw subprocess patterns in services + # ────────────────────────────────────────────────────────────────── + audit-raw-patterns: + name: Audit Raw Subprocess Patterns + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Check for raw 'sudo systemctl' calls in migrated services + run: | + MIGRATED_FILES=( + backend/app/services/firewall_service.py + backend/app/services/security_service.py + backend/app/services/python_service.py + backend/app/services/deployment_service.py + backend/app/services/ftp_service.py + backend/app/services/nginx_service.py + backend/app/services/php_service.py + backend/app/services/ssl_service.py + ) + + EXIT_CODE=0 + + echo "=== Checking for raw subprocess patterns ===" + echo "" + + # Pattern 1: subprocess.run with 'sudo', 'systemctl' + echo "--- Pattern: 'sudo', 'systemctl' ---" + for f in "${MIGRATED_FILES[@]}"; do + if [ -f "$f" ]; then + MATCHES=$(grep -n "'sudo', 'systemctl'" "$f" || true) + if [ -n "$MATCHES" ]; then + echo "FAIL: $f" + echo "$MATCHES" + EXIT_CODE=1 + fi + fi + done + + # Pattern 2: subprocess.run with dpkg + echo "" + echo "--- Pattern: subprocess.run.*dpkg ---" + for f in "${MIGRATED_FILES[@]}"; do + if [ -f "$f" ]; then + MATCHES=$(grep -n "subprocess\.run.*dpkg" "$f" || true) + if [ -n "$MATCHES" ]; then + echo "FAIL: $f" + echo "$MATCHES" + EXIT_CODE=1 + fi + fi + done + + # Pattern 3: os.path.exists('/usr/bin/apt') + echo "" + echo "--- Pattern: os.path.exists('/usr/bin/apt') ---" + for f in "${MIGRATED_FILES[@]}"; do + if [ -f "$f" ]; then + MATCHES=$(grep -n "os\.path\.exists.*'/usr/bin/apt'" "$f" || true) + if [ -n "$MATCHES" ]; then + echo "FAIL: $f" + echo "$MATCHES" + EXIT_CODE=1 + fi + fi + done + + echo "" + if [ $EXIT_CODE -eq 0 ]; then + echo "All migrated files are clean." + else + echo "Raw subprocess patterns found — see failures above." + fi + exit $EXIT_CODE diff --git a/backend/tests/test_utils_system_integration.py b/backend/tests/test_utils_system_integration.py new file mode 100644 index 00000000..6b91635f --- /dev/null +++ b/backend/tests/test_utils_system_integration.py @@ -0,0 +1,137 @@ +"""Integration smoke tests for system utilities on real Linux distros. + +These run WITHOUT mocks inside actual distro containers (Ubuntu, Fedora, +Rocky Linux) to verify detection logic works against real package managers +and systemctl. Designed for CI — skipped on Windows/macOS. +""" + +import os +import platform +import subprocess +import sys + +import pytest + +# Skip entire module on non-Linux +pytestmark = pytest.mark.skipif( + platform.system() != 'Linux', + reason='Integration tests require Linux', +) + +# Direct import (same technique as unit tests) to avoid Flask deps. +import importlib +import types + +_backend = os.path.join(os.path.dirname(__file__), os.pardir) +_mod_path = os.path.join(_backend, 'app', 'utils', 'system.py') +_spec = importlib.util.spec_from_file_location('app.utils.system', _mod_path) +_module = importlib.util.module_from_spec(_spec) + +if 'app' not in sys.modules: + sys.modules['app'] = types.ModuleType('app') +if 'app.utils' not in sys.modules: + _utils = types.ModuleType('app.utils') + sys.modules['app.utils'] = _utils + sys.modules['app'].utils = _utils +sys.modules['app.utils.system'] = _module +sys.modules['app.utils'].system = _module +_spec.loader.exec_module(_module) + +PackageManager = _module.PackageManager +ServiceControl = _module.ServiceControl +is_command_available = _module.is_command_available +run_privileged = _module.run_privileged + + +class TestPackageManagerDetection: + """Verify PackageManager.detect() returns the correct manager for the distro.""" + + def setup_method(self): + PackageManager.reset_cache() + + def test_detect_returns_known_manager(self): + """On any supported Linux, detect() should find apt, dnf, or yum.""" + result = PackageManager.detect() + assert result in ('apt', 'dnf', 'yum'), ( + f'Expected apt/dnf/yum but got {result!r} — ' + f'is this an unsupported distro?' + ) + + def test_detect_matches_distro(self): + """The detected manager should match the actual distro family.""" + manager = PackageManager.detect() + + # Read os-release to determine distro family + distro_id = '' + id_like = '' + if os.path.exists('/etc/os-release'): + with open('/etc/os-release') as f: + for line in f: + if line.startswith('ID='): + distro_id = line.split('=', 1)[1].strip().strip('"') + elif line.startswith('ID_LIKE='): + id_like = line.split('=', 1)[1].strip().strip('"') + + apt_distros = ('ubuntu', 'debian', 'linuxmint', 'pop') + dnf_distros = ('fedora', 'rhel', 'centos', 'rocky', 'alma', 'ol') + + if distro_id in apt_distros or any(d in id_like for d in ('debian', 'ubuntu')): + assert manager == 'apt', f'Debian-family distro ({distro_id}) should use apt, got {manager}' + elif distro_id in dnf_distros or 'rhel' in id_like or 'fedora' in id_like: + assert manager in ('dnf', 'yum'), f'RHEL-family distro ({distro_id}) should use dnf/yum, got {manager}' + + def test_detect_is_cached(self): + """Calling detect() twice should return the same cached value.""" + first = PackageManager.detect() + second = PackageManager.detect() + assert first == second + + def test_is_available(self): + """On any CI Linux container, a package manager should be available.""" + assert PackageManager.is_available() is True + + +class TestIsCommandAvailable: + """Verify is_command_available() finds real binaries.""" + + def test_finds_python(self): + assert is_command_available('python3') is True + + def test_finds_bash(self): + assert is_command_available('bash') is True + + def test_missing_binary(self): + assert is_command_available('this_binary_does_not_exist_xyz') is False + + @pytest.mark.skipif( + not os.path.exists('/usr/bin/apt'), + reason='apt not available on this distro', + ) + def test_finds_apt(self): + assert is_command_available('apt') is True + + @pytest.mark.skipif( + not os.path.exists('/usr/bin/dnf'), + reason='dnf not available on this distro', + ) + def test_finds_dnf(self): + assert is_command_available('dnf') is True + + +class TestServiceControlSmoke: + """Smoke-test ServiceControl against real systemctl (if present).""" + + @pytest.mark.skipif( + not os.path.exists('/usr/bin/systemctl') and not os.path.exists('/bin/systemctl'), + reason='systemctl not available in this container', + ) + def test_is_active_nonexistent_service(self): + """A service that doesn't exist should not be active.""" + assert ServiceControl.is_active('this_service_does_not_exist_xyz') is False + + @pytest.mark.skipif( + not os.path.exists('/usr/bin/systemctl') and not os.path.exists('/bin/systemctl'), + reason='systemctl not available in this container', + ) + def test_is_enabled_nonexistent_service(self): + assert ServiceControl.is_enabled('this_service_does_not_exist_xyz') is False From 7f80932c87f36e2ad6078dc38e423aa987657802 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 10 Feb 2026 13:26:04 +0000 Subject: [PATCH 2/7] chore: bump version to 1.2.82 [skip ci] --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 3a67429d..aea11868 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.81 +1.2.82 From d5f6e13d045d957210b605cd68803a477470fc48 Mon Sep 17 00:00:00 2001 From: Juan Denis Date: Tue, 10 Feb 2026 16:03:19 -0500 Subject: [PATCH 3/7] Move EXPECTED_MANAGER check into pytest test Remove the inline "Verify expected package manager" step from the GitHub Actions workflow and add a pytest in backend/tests/test_utils_system_integration.py. The new test (test_matches_expected_manager_from_ci) reads EXPECTED_MANAGER from the environment, skips when not set, and asserts PackageManager.detect() matches the expected value. This consolidates the CI verification into the test suite and simplifies the workflow. --- .github/workflows/test-system-utils.yml | 27 ------------------- .../tests/test_utils_system_integration.py | 10 +++++++ 2 files changed, 10 insertions(+), 27 deletions(-) diff --git a/.github/workflows/test-system-utils.yml b/.github/workflows/test-system-utils.yml index 956685e1..fe6bb77a 100644 --- a/.github/workflows/test-system-utils.yml +++ b/.github/workflows/test-system-utils.yml @@ -84,33 +84,6 @@ jobs: EXPECTED_MANAGER: ${{ matrix.expected_manager }} run: python3 -m pytest tests/test_utils_system_integration.py -v - - name: Verify expected package manager - working-directory: backend - env: - EXPECTED_MANAGER: ${{ matrix.expected_manager }} - run: | - python3 -c " - import importlib, os, sys, types - - # Direct import to avoid Flask deps - mod_path = os.path.join('app', 'utils', 'system.py') - spec = importlib.util.spec_from_file_location('app.utils.system', mod_path) - mod = importlib.util.module_from_spec(spec) - sys.modules['app'] = types.ModuleType('app') - utils = types.ModuleType('app.utils') - sys.modules['app.utils'] = utils - sys.modules['app'].utils = utils - sys.modules['app.utils.system'] = mod - sys.modules['app.utils'].system = mod - spec.loader.exec_module(mod) - - expected = os.environ['EXPECTED_MANAGER'] - detected = mod.PackageManager.detect() - print(f'Distro detected: {detected} (expected: {expected})') - assert detected == expected, f'FAIL: expected {expected}, got {detected}' - print('PASS') - " - # ────────────────────────────────────────────────────────────────── # Job 3: Audit — grep for raw subprocess patterns in services # ────────────────────────────────────────────────────────────────── diff --git a/backend/tests/test_utils_system_integration.py b/backend/tests/test_utils_system_integration.py index 6b91635f..19df21d6 100644 --- a/backend/tests/test_utils_system_integration.py +++ b/backend/tests/test_utils_system_integration.py @@ -90,6 +90,16 @@ def test_is_available(self): """On any CI Linux container, a package manager should be available.""" assert PackageManager.is_available() is True + def test_matches_expected_manager_from_ci(self): + """When CI sets EXPECTED_MANAGER, verify detect() agrees.""" + expected = os.environ.get('EXPECTED_MANAGER') + if expected is None: + pytest.skip('EXPECTED_MANAGER not set (not running in CI matrix)') + detected = PackageManager.detect() + assert detected == expected, ( + f'CI matrix expects {expected!r} but detect() returned {detected!r}' + ) + class TestIsCommandAvailable: """Verify is_command_available() finds real binaries.""" From 926c2f3afdbf5a9ec1e0c7eea8520a2b06c7acba Mon Sep 17 00:00:00 2001 From: Juan Denis Date: Tue, 10 Feb 2026 16:33:10 -0500 Subject: [PATCH 4/7] CI: set noninteractive env and harden install step Set DEBIAN_FRONTEND=noninteractive to prevent interactive prompts during image package installs, add shell: bash for the Install step, and normalize command -v checks/redirection so package manager detection and installs behave more reliably. Also remove excessive suppression of apt-get update/install output to surface failures; the pip3 pytest install fallback remains unchanged. --- .github/workflows/test-system-utils.yml | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/.github/workflows/test-system-utils.yml b/.github/workflows/test-system-utils.yml index fe6bb77a..31ba1e6d 100644 --- a/.github/workflows/test-system-utils.yml +++ b/.github/workflows/test-system-utils.yml @@ -64,17 +64,22 @@ jobs: container: image: ${{ matrix.image }} + env: + DEBIAN_FRONTEND: noninteractive + steps: - uses: actions/checkout@v4 - name: Install Python & pytest + shell: bash run: | - if command -v apt-get &>/dev/null; then - apt-get update -qq && apt-get install -y -qq python3 python3-pip >/dev/null 2>&1 - elif command -v dnf &>/dev/null; then - dnf install -y -q python3 python3-pip >/dev/null 2>&1 - elif command -v yum &>/dev/null; then - yum install -y -q python3 python3-pip >/dev/null 2>&1 + if command -v apt-get >/dev/null 2>&1; then + apt-get update -q + apt-get install -y -q python3 python3-pip + elif command -v dnf >/dev/null 2>&1; then + dnf install -y -q python3 python3-pip + elif command -v yum >/dev/null 2>&1; then + yum install -y -q python3 python3-pip fi pip3 install --break-system-packages pytest 2>/dev/null || pip3 install pytest From deb29d3678ee4458c87b9a7bf6cb54ef3fa77451 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 10 Feb 2026 21:33:19 +0000 Subject: [PATCH 5/7] chore: bump version to 1.2.83 [skip ci] --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index aea11868..7000921a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.82 +1.2.83 From e1f732c7e970e0bfbba177f2d69b920eaf9274fe Mon Sep 17 00:00:00 2001 From: Juan Denis Date: Tue, 10 Feb 2026 16:36:11 -0500 Subject: [PATCH 6/7] Update CI images and package install steps Bump distro images (ubuntu:22.04 -> ubuntu:24.04, fedora:40 -> fedora:41) in the system utils test workflow. Make package installation commands less quiet and more robust: clear /var/lib/apt/lists/* before apt-get update and remove -q flags from apt-get/dnf/yum installs so logs are more informative and apt updates won't fail on stale lists. --- .github/workflows/test-system-utils.yml | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/.github/workflows/test-system-utils.yml b/.github/workflows/test-system-utils.yml index 31ba1e6d..8dae39e2 100644 --- a/.github/workflows/test-system-utils.yml +++ b/.github/workflows/test-system-utils.yml @@ -49,13 +49,13 @@ jobs: matrix: include: - distro: ubuntu - image: ubuntu:22.04 + image: ubuntu:24.04 expected_manager: apt - distro: debian image: debian:12 expected_manager: apt - distro: fedora - image: fedora:40 + image: fedora:41 expected_manager: dnf - distro: rocky image: rockylinux:9 @@ -74,12 +74,13 @@ jobs: shell: bash run: | if command -v apt-get >/dev/null 2>&1; then - apt-get update -q - apt-get install -y -q python3 python3-pip + rm -rf /var/lib/apt/lists/* + apt-get update + apt-get install -y python3 python3-pip elif command -v dnf >/dev/null 2>&1; then - dnf install -y -q python3 python3-pip + dnf install -y python3 python3-pip elif command -v yum >/dev/null 2>&1; then - yum install -y -q python3 python3-pip + yum install -y python3 python3-pip fi pip3 install --break-system-packages pytest 2>/dev/null || pip3 install pytest From 34ca8dee8fc5b1d03379144f1484be9227eaab35 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 10 Feb 2026 21:36:42 +0000 Subject: [PATCH 7/7] chore: bump version to 1.2.84 [skip ci] --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 7000921a..101bb3a7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.83 +1.2.84