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
3 changes: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python: ['3.11', '3.12', '3.13', '3.14']
python: ['3.11', '3.12', '3.13', '3.14', '3.14t']
runs-on: ubuntu-latest
steps:
- name: Checkout repository
Expand Down Expand Up @@ -42,6 +42,7 @@ jobs:
pystemd
examples
tests
e2e
- name: Run isort
uses: isort/isort-action@v1

Expand Down
86 changes: 86 additions & 0 deletions .github/workflows/e2e-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
name: E2E Tests

on:
push:
branches: [ main ]
pull_request:
branches: [ main ]

jobs:
e2e-tests:
name: Python ${{ matrix.python-version }}
runs-on: ubuntu-latest

strategy:
matrix:
python-version: ['3.11', '3.12', '3.13', '3.14', '3.14t']

env:
PYTHON_VERSION: ${{ matrix.python-version }}
UNIT_NAME: pystemd-e2e-py${{ matrix.python-version }}.service
MACHINE_NAME: pystemd-test-py${{ matrix.python-version }}

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up latest Python
uses: actions/setup-python@v4
with:
python-version: '3.14'

- name: setup-mkosi
uses: systemd/mkosi@v26

- name: Install systemd-container
run: |
sudo apt-get update
sudo apt-get install -y systemd-container

- name: Generate mkosi keys
run: |
sudo mkosi genkey

- name: Build mkosi test image
run: |
sudo mkosi -E "$PYTHON_VERSION" build

- name: Boot container
run: |
sudo systemd-run --unit "$UNIT_NAME" --same-dir \
systemd-nspawn \
--machine="$MACHINE_NAME" \
--boot \
--directory=pystemd-test \
--bind-ro=${{ github.workspace }}/e2e:/opt/pystemd/e2e

# Wait for container to be ready
for i in {1..30}; do
if sudo systemd-run --machine="$MACHINE_NAME" --wait --pipe /bin/true 2>/dev/null; then
echo "Container is ready"
break
fi
echo "Waiting for container to start... ($i/30)"
sleep 1
done
sudo journalctl -u "$UNIT_NAME"
sleep 1
sudo systemd-run --machine="$MACHINE_NAME" --wait --pipe /bin/echo 'hello world' || exit 1

- name: Run E2E tests
run: |
sudo systemd-run \
--machine="$MACHINE_NAME" \
--wait \
--pipe \
--setenv=PYSTEMD_E2E_CONTAINER=1\
--property=PrivateTmp=true \
-- \
/opt/pystemd/venv/bin/pytest \
-o cache_dir=/tmp/pytest_cache \
/opt/pystemd/e2e/ -v

- name: Stop container
if: always()
run: |
sudo machinectl terminate "$MACHINE_NAME" || true
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -98,3 +98,10 @@ pystemd/RELEASE

# not a fan of keeping uv lock files around
uv.lock

# mkosi generated files
pystemd-test/
pystemd-test-*/
.#pystemd-test*.lck
mkosi.key
mkosi.crt
185 changes: 185 additions & 0 deletions E2E_TESTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
# pystemd E2E Testing Setup

## Overview

This setup provides comprehensive end-to-end testing for pystemd using mkosi and systemd-nspawn. Tests run in a real systemd environment inside a container to ensure accurate testing of systemd integration.

## Quick Start

```bash
# Install dependencies (one-time setup)
sudo dnf install mkosi systemd-container # Fedora/RHEL
# or
sudo apt install mkosi systemd-container # Debian/Ubuntu

# generate keys if they dont exists
mkosi genkey

# build container, there steps do not need to be run as root.
mkosi clean && mkosi build

# Start container
UNIT_NAME=pystemd-e2e-local.service
MACHINE_NAME=pystemd-test-local
sudo systemd-run --unit "$UNIT_NAME" --same-dir \
systemd-nspawn \
--machine="$MACHINE_NAME" \
--boot \
--directory=pystemd-test \
--bind-ro=`pwd`/e2e:/opt/pystemd/e2e

# Run all E2E tests
sudo systemd-run \
--machine="$MACHINE_NAME" \
--wait \
--pipe \
--setenv=PYSTEMD_E2E_CONTAINER=1\
--property=PrivateTmp=true \
-- \
/opt/pystemd/venv/bin/pytest \
-o cache_dir=/tmp/pytest_cache \
/opt/pystemd/e2e/ -v
```


## How It Works

### Manual Container Management

The E2E testing workflow uses a straightforward approach:

1. **Build the container image** using mkosi - this creates a Fedora environment with pystemd installed
2. **Boot the container** using systemd-nspawn as a background service
3. **Run tests inside the container** using `systemd-run --machine`
4. **Stop the container** when done

The container runs with `--boot` which starts a full systemd init inside, providing a realistic systemd environment for testing.

### Container Boot Command

The container is booted as a systemd service using systemd-nspawn:

```bash
UNIT_NAME=pystemd-e2e-local.service
MACHINE_NAME=pystemd-test

sudo systemd-run --unit "$UNIT_NAME" --same-dir \
systemd-nspawn \
--machine="$MACHINE_NAME" \
--boot \
--directory=pystemd-test \
--bind-ro=`pwd`/e2e:/opt/pystemd/e2e
```

Tests are then executed inside the container using:

```bash
sudo systemd-run \
--machine="$MACHINE_NAME" \
--wait \
--pipe \
--setenv=PYSTEMD_E2E_CONTAINER=1 \
--property=PrivateTmp=true \
-- \
/opt/pystemd/venv/bin/pytest \
-o cache_dir=/tmp/pytest_cache \
/opt/pystemd/e2e/ -v
```


## Test Suite

Tests are located in the `e2e/` directory:

- **`test_pystemd_run.py`** - Tests for `pystemd.run()`
- **`test_manager.py`** - Tests for systemd Manager API
- **`test_unit.py`** - Tests for Unit operations
- **`test_transient_units.py`** - Tests for transient unit creation
- **`test_dbus.py`** - Tests for D-Bus connections

## Adding New Tests

Create a new test file in the `e2e/` directory:

```python
# e2e/test_my_feature.py
import pystemd.run

def test_my_feature():
"""Test my new feature"""
unit = pystemd.run([b'/bin/echo', b'hello'], wait=True)
assert unit.Service.ExecMainStatus == 0
```

```bash
sudo systemd-run \
--machine="$MACHINE_NAME" \
--wait \
--pipe \
--setenv=PYSTEMD_E2E_CONTAINER=1 \
--property=PrivateTmp=true \
-- \
/opt/pystemd/venv/bin/pytest \
-o cache_dir=/tmp/pytest_cache \
/opt/pystemd/e2e/test_my_feature.py -v
```


And run it using

## CI/CD Integration

The GitHub Actions workflow (`.github/workflows/e2e-tests.yml`) automates E2E testing:

**Triggers:**
- Pushes to `main` or `develop` branches
- Pull requests targeting `main`

**Matrix Testing:**
- Tests across multiple Python versions: 3.11, 3.12, 3.13, 3.14, 3.14t (free-threaded)
- Each Python version runs in its own container instance


## Troubleshooting

### Container fails to start
```bash
# Check if another container is running
sudo machinectl list

# SSH into the container
sudo machinectl shell pystemd-test

# Terminate stale container
sudo machinectl terminate pystemd-test
```

### Image not found
```bash
# Rebuild the mkosi image
sudo mkosi --force build
```

### Tests hang
```bash
# Check container status
sudo machinectl status pystemd-test

# View container logs
sudo journalctl -M pystemd-test
```

### Building with a different Python version

By default, the container is built with Python 3.14. To build with a different Python version, set the `PYTHON_VERSION` environment variable before building:

```bash
# Build with Python 3.12
mkosi clean && mkosi -E PYTHON_VERSION=3.12 build

# Build with Python 3.14 free-threaded
mkosi clean && mkosi-E PYTHON_VERSION=3.14t build
```

The `-E` flag passes the `PYTHON_VERSION` environment variable to mkosi, which is then used by `mkosi.build.chroot` to install the specified Python version using `uv python install`.

9 changes: 9 additions & 0 deletions e2e/test_dbus.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
"""E2E tests for D-Bus integration"""

from pystemd.dbuslib import DBus


def test_dbus_connection():
"""Test basic D-Bus connection"""
with DBus() as bus:
assert bus is not None
48 changes: 48 additions & 0 deletions e2e/test_manager.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""E2E tests for pystemd Manager functionality"""

from pystemd.systemd1 import Manager


def test_manager_version():
"""Test getting systemd version"""
with Manager() as manager:
version = manager.Manager.Version
assert version is not None
assert isinstance(version, bytes)


def test_manager_architecture():
"""Test getting system architecture"""
with Manager() as manager:
arch = manager.Manager.Architecture
assert arch is not None
assert isinstance(arch, bytes)


def test_list_units():
"""Test listing units"""
with Manager() as manager:
units = manager.Manager.ListUnits()
assert len(units) > 0
# Each unit should be a tuple with multiple fields
assert isinstance(units[0], tuple)


def test_list_unit_files():
"""Test listing unit files"""
with Manager() as manager:
unit_files = manager.Manager.ListUnitFiles()
assert len(unit_files) > 0
# Each should be (name, state) tuple
for name, state in unit_files:
assert isinstance(name, bytes)
assert isinstance(state, bytes)


def test_get_unit():
"""Test getting a unit by name"""
with Manager() as manager:
# Get a unit that should always exist
unit_path = manager.Manager.GetUnit(b"dbus.service")
assert unit_path is not None
assert isinstance(unit_path, bytes)
Loading