Compare commits

...

6 Commits
v0.9 ... main

Author SHA1 Message Date
Yariv Rachmani c9868e8507
Merge pull request #910 from containers/hostname
setup: install hostname
2025-08-10 08:14:36 +03:00
Douglas Schilling Landgraf 5938fa91fe setup: install hostname
QE scripts need hostname pkg

Signed-off-by: Douglas Schilling Landgraf <dougsland@redhat.com>
2025-08-08 12:07:09 -04:00
Albert Esteve 3c960da3cc
oci-hooks: Refactor and test (#896)
Merge qm-oci-hooks-wayland and device
hooks to reuse the scrip file.

Add tests to the infrastructure.

Split some library files to facilitate
testing by adding some mocking functions.

Assisted-by: Cursor - claude-4-sonnet model

Signed-off-by: Albert Esteve <aesteve@redhat.com>
2025-08-07 10:51:33 -04:00
Yariv Rachmani 1e6111f2d4
Adding verification check for flood testing (#908)
Signed-off-by: Yariv Rachmani <yrachman@redhat.com>
2025-08-07 10:11:16 -04:00
Yariv Rachmani c84b01d598
Merge pull request #906 from containers/reducefiles
Makefile: reduce the make dist files
2025-08-07 11:24:22 +03:00
Douglas Schilling Landgraf 08fb1c1a66 Makefile: reduce the make dist files
Signed-off-by: Douglas Schilling Landgraf <dougsland@redhat.com>
2025-08-05 21:49:20 -04:00
36 changed files with 3324 additions and 497 deletions

47
.github/workflows/oci-hooks-tests.yml vendored Normal file
View File

@ -0,0 +1,47 @@
name: OCI Hooks Tests
on:
push:
paths:
- 'oci-hooks/**'
pull_request:
paths:
- 'oci-hooks/**'
jobs:
test:
runs-on: ubuntu-latest
container: python:3.11-slim
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.21'
- name: Install dependencies
run: |
apt-get update && apt-get install -y jq
pip install tox
# Install shfmt for shell script formatting
go install mvdan.cc/sh/v3/cmd/shfmt@latest
- name: Make OCI hook scripts executable
run: |
chmod +x oci-hooks/qm-device-manager/oci-qm-device-manager
chmod +x oci-hooks/wayland-client-devices/oci-qm-wayland-client-devices
- name: Run code quality checks
run: |
cd oci-hooks
tox -e lint
- name: Run tests
env:
FORCE_MOCK_DEVICES: true
run: |
cd oci-hooks
tox -e all

View File

@ -58,7 +58,15 @@ dist: ## - Creates the QM distribution package
--exclude='.git' \
--dereference \
--exclude='.gitignore' \
--exclude='.fmf' \
--exclude='.packit.*' \
--exclude='.pre-commit*' \
--exclude='.readthedocs.yaml' \
--exclude='demos' \
--exclude='docs' \
--exclude='plans' \
--exclude='subsystems' \
--exclude='tests' \
--exclude='.github' \
--transform s/qm/qm-${VERSION}/ \
-f /tmp/v${VERSION}.tar.gz ../qm

View File

@ -215,14 +215,13 @@ hw:1,0: sound card 1, device 0
## QM sub-package OCI Hooks
The QM sub-package OCI Hooks provides dynamic device access management for containers through OCI runtime hooks. This subpackage includes three essential hooks that enable secure and flexible device sharing between the host system and containers.
The QM sub-package OCI Hooks provides dynamic device access management for containers through OCI runtime hooks. This subpackage includes essential hooks that enable secure and flexible device sharing between the host system and containers with robust error handling and comprehensive testing.
### Components
The `qm-oci-hooks` subpackage includes:
- **qm-device-manager**: Dynamic device mounting hook that provides access to various hardware devices based on container annotations
- **wayland-session-devices**: Hook for Wayland display server device management in multi-seat environments, providing access to systemd-logind seat devices (input devices, render devices, display devices)
- **wayland-client-devices**: Hook for GPU hardware acceleration access for Wayland client applications running as nested containers
### Supported Device Types
@ -234,19 +233,12 @@ The `qm-device-manager` hook supports the following device types through contain
| Device Type | Annotation | Devices Provided |
|-------------|------------|------------------|
| Audio | `org.containers.qm.device.audio=true` | `/dev/snd/*` (audio devices) |
| Video | `org.containers.qm.device.video=true` | `/dev/video*` (cameras, video devices) |
| Video | `org.containers.qm.device.video=true` | `/dev/video*`, `/dev/media*` (cameras, video devices) |
| Input | `org.containers.qm.device.input=true` | `/dev/input/*` (keyboards, mice, touchpads) |
| TTYs | `org.containers.qm.device.ttys=true` | `/dev/tty0-7` (virtual terminals) |
| TTY USB | `org.containers.qm.device.ttyUSB=true` | `/dev/ttyUSB*` (USB TTY devices) |
| DVB | `org.containers.qm.device.dvb=true` | `/dev/dvb/*` (digital TV devices) |
| Radio | `org.containers.qm.device.radio=true` | `/dev/radio*` (radio devices) |
#### Wayland Session Devices
The `wayland-session-devices` hook supports:
| Functionality | Annotation | Devices Provided |
|---------------|------------|------------------|
| Multi-seat Support | `org.containers.qm.wayland.seat=seat0` | Input devices, render devices, and display devices associated with the specified systemd-logind seat |
#### Wayland Client Devices
@ -255,17 +247,17 @@ The `wayland-client-devices` hook supports:
| Functionality | Annotation | Devices Provided |
|---------------|------------|------------------|
| GPU Acceleration | `org.containers.qm.wayland-client.gpu=true` | GPU render devices (`/dev/dri/*`) for hardware acceleration |
| GPU Acceleration | `org.containers.qm.wayland-client.gpu=true` | GPU render devices (`/dev/dri/render*`) for hardware acceleration |
### Features
- **Dynamic Device Discovery**: Automatically discovers and mounts available devices at container startup
- **Annotation-Based Security**: Devices are only mounted when explicitly requested via annotations
- **Multi-seat Support**: Enables proper device access in systemd-logind multi-seat environments
- **Comprehensive Mock Device Support**: Full testing infrastructure with mock devices for all device types
- **GPU Acceleration**: Provides hardware acceleration for Wayland client applications
- **Comprehensive Logging**: All hooks provide detailed logging for monitoring and debugging:
- Device Manager: `/var/log/qm-device-manager.log`
- Wayland Session: `/var/log/qm-wayland-session-devices.log`
- Wayland Client: `/var/log/qm-wayland-client-devices.log`
- **Runtime Flexibility**: No system restart required when adding device access to new containers
- **Lightweight Implementation**: Shell script-based hooks with minimal dependencies
@ -280,28 +272,7 @@ sudo dnf install rpmbuild/RPMS/noarch/qm-oci-hooks-*.noarch.rpm
### Usage Examples
#### Example 1: Audio application with device access
```bash
# Create a container file that needs audio access
cat > /etc/containers/systemd/my-audio-app.container << EOF
[Unit]
Description=Audio Application Container
[Container]
Image=quay.io/qm-images/audio:latest
Annotation=org.containers.qm.device.audio=true
[Install]
WantedBy=default.target
EOF
# Enable and start the container
sudo systemctl daemon-reload
sudo systemctl enable --now my-audio-app
```
#### Example 2: Serial communication with USB TTY devices
#### Example 1: Serial communication with USB TTY devices
```bash
# Create a container with access to all USB TTY devices
@ -318,7 +289,7 @@ WantedBy=default.target
EOF
```
#### Example 3: Multi-seat Wayland compositor with session devices
#### Example 2: Multi-seat Wayland compositor with session devices
```bash
# Create a dropin for QM container to enable multi-seat support
@ -342,7 +313,7 @@ WantedBy=default.target
EOF
```
#### Example 4: Wayland client application with GPU acceleration
#### Example 3: Wayland client application with GPU acceleration
```bash
# Create a Wayland client container with GPU hardware acceleration
@ -370,9 +341,11 @@ ls -la /usr/share/containers/oci/hooks.d/
# Check hook executables
ls -la /usr/libexec/oci/hooks.d/
# Verify hook JSON configurations are valid
find /usr/share/containers/oci/hooks.d/ -name "*.json" -exec jq . {} \;
# View hook logs (all hooks provide comprehensive logging)
tail -f /var/log/qm-device-manager.log
tail -f /var/log/qm-wayland-session-devices.log
tail -f /var/log/qm-wayland-client-devices.log
# Test device access with qm-device-manager
@ -381,11 +354,101 @@ podman exec -it my-audio-app ls -la /dev/snd/
# Test GPU access with wayland-client-devices (if applicable)
podman exec -it gpu-app ls -la /dev/dri/
# Check systemd-logind seat devices (for wayland-session-devices)
loginctl list-seats
loginctl seat-status seat0
# Run hook tests (if source available)
cd oci-hooks && tox -e all
```
### Testing and Development
The OCI hooks include a comprehensive test suite for development and validation:
```bash
# Run all tests
cd oci-hooks && tox -e all
# Run specific test categories
tox -e unit # Unit tests only
tox -e integration # Integration tests only
tox -e performance # Performance tests only
# Run linting and formatting
tox -e lint # Code linting
tox -e format # Code formatting
# Test with mock devices (useful for CI environments)
FORCE_MOCK_DEVICES=true tox -e unit -- -k "mock_devices"
```
### OCI Hooks Specification and Documentation
The QM OCI hooks are implemented according to the [Open Container Initiative (OCI) Runtime Specification](https://github.com/opencontainers/runtime-spec).
#### OCI Hook Configuration Format
OCI hooks are configured using JSON files that define when and how the hooks should be executed. Each hook configuration follows the structure defined in the [OCI config schema](https://github.com/opencontainers/runtime-spec/blob/main/schema/config-schema.json).
**Key components of hook configuration:**
- **version**: OCI specification version (e.g., `"1.0.0"`)
- **hook**: Object defining the hook executable and arguments
- **when**: Object defining trigger conditions for the hook
- **stages**: Array of lifecycle stages when the hook should run
#### Hook Lifecycle Stages
According to the [OCI POSIX Platform Hooks specification](https://github.com/opencontainers/runtime-spec/blob/main/config.md#posix-platform-hooks), hooks can be executed at three stages:
1. **prestart**: Hooks called after the container process is spawned, but before the user-supplied command is executed
2. **poststart**: Hooks called after the user-supplied command is executed
3. **poststop**: Hooks called after the container process is terminated
QM hooks execution stages:
- `qm-device-manager`: Runs during **prestart** to mount devices before the container starts
- `wayland-client-devices`: Runs during **prestart** to provide GPU access before the container starts
#### Hook Input/Output Specification
OCI hooks receive container state information via **stdin** and communicate results via **stdout/stderr** and **exit codes**:
**Input (stdin)**: JSON object containing container state according to OCI spec:
```json
{
"ociVersion": "1.0.0",
"id": "container-id",
"status": "creating",
"pid": 1234,
"bundle": "/path/to/bundle",
"annotations": {
"org.containers.qm.device.audio": "true"
}
}
```
**Output**:
- **Exit code 0**: Success
- **Exit code non-zero**: Failure (container creation aborted)
- **stderr**: Error messages and diagnostics
- **stdout**: Hook output (typically empty for QM hooks)
#### Annotation Pattern Matching
QM hooks use regular expressions in their `when.annotations` configuration to match container annotations:
- `org\\.containers\\.qm\\.device\\.(audio|video|input|ttys|ttyUSB|dvb|radio)`: Matches device-specific annotations
- `org\\.containers\\.qm\\.wayland\\.seat`: Matches Wayland seat annotations with any value
- `org\\.containers\\.qm\\.wayland-client\\.gpu`: Matches GPU acceleration requests
#### Hook Installation Locations
QM hooks are installed in standard OCI locations:
- Hook executables: `/usr/libexec/oci/hooks.d/`
- Hook configurations: `/usr/share/containers/oci/hooks.d/`
- Hook libraries: `/usr/libexec/oci/lib/`
## QM sub-package ROS2
The QM sub-package ROS2 (a.k.a "The Robot Operating System" or middleware for robots) is widely used by open-source projects, enterprises, companies, edge env and government agencies, including NASA, to advance robotics and autonomous systems. Enabled by Quadlet in QM, ROS2 on top of QM provides a secure environment where robots can operate and communicate safely, benefiting from QM's "Freedom from Interference" frequently tested layer. This ensures robots can function without external interference, enhancing their reliability and security.

21
oci-hooks/lib/common.sh Normal file
View File

@ -0,0 +1,21 @@
#!/bin/bash
# Common Utility Library for OCI Hooks
# This file provides shared utility functions for all OCI hooks.
# Common logging function for OCI hooks
log() {
local level="$1"
shift
local message
message="$(date '+%Y-%m-%d %H:%M:%S') - ${HOOK_NAME:-oci-hook} - $level - $*"
# Write to log file if LOGFILE is set
if [[ -n "${LOGFILE:-}" ]]; then
echo "$message" >>"$LOGFILE"
fi
# Also write errors to stderr
if [[ "$level" == "ERROR" ]]; then
echo "$message" >&2
fi
}

View File

@ -0,0 +1,82 @@
#!/bin/bash
# Device Support Library for OCI Hooks
# This file provides standard device discovery functionality for OCI hooks.
# Source common utilities
# shellcheck source=./common.sh disable=SC1091
source "$(dirname "${BASH_SOURCE[0]}")/common.sh"
# Device discovery function using standard filesystem operations
discover_devices() {
local pattern="$1"
local device_type="$2"
# Extract directory path from pattern to check existence first
local dir_path=""
if [[ "$pattern" =~ find[[:space:]]+(/[^[:space:]]+) ]]; then
dir_path="${BASH_REMATCH[1]}"
fi
# Check if specific directory exists (for patterns that search specific dirs)
if [[ -n "$dir_path" && "$dir_path" != "/dev" ]]; then
if [[ ! -d "$dir_path" ]]; then
# Directory doesn't exist - return empty, not an error
return 0
fi
fi
# Normal device discovery
eval "$pattern" 2>/dev/null || true
}
# Get device information for a given device path
get_device_info() {
local device_path="$1"
if [[ ! -e "$device_path" ]]; then
return 1
fi
if [[ ! -c "$device_path" ]]; then
return 1
fi
local stat_output
if ! stat_output=$(stat -c "%F:%t:%T:%f:%u:%g" "$device_path" 2>/dev/null); then
return 1
fi
local major minor file_mode uid gid
IFS=':' read -r _ major minor file_mode uid gid <<<"$stat_output"
# Convert hex to decimal
major=$((0x$major))
minor=$((0x$minor))
file_mode=$((0x$file_mode))
# Determine device type
local device_type="c"
# Return colon-separated values
echo "$device_type:$major:$minor:$file_mode:$uid:$gid"
}
# Check if device directory exists
should_process_device_type() {
local device_type="$1"
local directory="$2"
# Process if the directory exists and is accessible
[[ -d "$directory" ]] 2>/dev/null
}
# GPU device discovery for wayland-client-devices
discover_gpu_devices() {
# Normal device discovery - check if directory exists first
if [[ -d "/dev/dri" ]]; then
find /dev/dri -type c \( -regex ".*/render.*" \) 2>/dev/null || true
else
# No GPU devices directory - return empty (not an error)
return 0
fi
}

View File

@ -0,0 +1,176 @@
#!/bin/bash
# Mock Device Support Library for OCI Hooks Testing
# This file provides device mocking functionality for testing OCI hooks
# without requiring actual system devices.
# Source common utilities
# shellcheck source=./common.sh disable=SC1091
source "$(dirname "${BASH_SOURCE[0]}")/common.sh"
# Device discovery function with test override support
discover_devices() {
local pattern="$1"
local device_type="$2"
# Check for test override environment variable
local override_var="TEST_MOCK_${device_type^^}_DEVICES"
local override_value="${!override_var:-}"
if [[ -n "$override_value" ]]; then
# Use test-provided mock devices
log "DEBUG" "Using mock $device_type devices: $override_value"
# Convert comma-separated devices to null-terminated individual paths
IFS=',' read -ra device_array <<<"$override_value"
for device in "${device_array[@]}"; do
printf '%s\0' "$device"
done
return
fi
# Extract directory path from pattern to check existence first
local dir_path=""
if [[ "$pattern" =~ find[[:space:]]+(/[^[:space:]]+) ]]; then
dir_path="${BASH_REMATCH[1]}"
fi
# Check if specific directory exists (for patterns that search specific dirs)
if [[ -n "$dir_path" && "$dir_path" != "/dev" ]]; then
if [[ ! -d "$dir_path" ]]; then
# Directory doesn't exist - return empty, not an error
return 0
fi
fi
# Normal device discovery fallback
eval "$pattern" 2>/dev/null || true
}
# Get device information - works with real temporary device files created by mknod
get_device_info() {
local device_path="$1"
# First try to get real device info (works if mknod succeeded)
if [[ -e "$device_path" && -c "$device_path" ]]; then
local stat_output
if stat_output=$(stat -c "%F:%t:%T:%f:%u:%g" "$device_path" 2>/dev/null); then
local major minor file_mode uid gid
IFS=':' read -r _ major minor file_mode uid gid <<<"$stat_output"
# Convert hex to decimal
major=$((0x$major))
minor=$((0x$minor))
file_mode=$((0x$file_mode))
# Determine device type
local device_type="c"
# Return colon-separated values
echo "$device_type:$major:$minor:$file_mode:$uid:$gid"
return 0
fi
fi
# If real device doesn't exist and we're in test mode, generate mock device info
if [[ "${FORCE_MOCK_DEVICES:-false}" == "true" || -n "${TEST_LOGFILE:-}" ]]; then
# Check if this path matches any of our test mock device patterns
local is_mock=false
for env_var in TEST_MOCK_AUDIO_DEVICES TEST_MOCK_VIDEO_DEVICES TEST_MOCK_INPUT_DEVICES \
TEST_MOCK_GPU_DEVICES TEST_MOCK_TTYUSB_DEVICES TEST_MOCK_DVB_DEVICES \
TEST_MOCK_RADIO_DEVICES TEST_MOCK_TTYS_DEVICES; do
if [[ -n "${!env_var:-}" ]]; then
IFS=',' read -ra mock_devices <<<"${!env_var}"
for mock_device in "${mock_devices[@]}"; do
if [[ "$mock_device" == "$device_path" ]]; then
is_mock=true
break 2
fi
done
fi
done
if [[ "$is_mock" == "true" ]]; then
# Generate mock device info based on device path patterns
local major minor file_mode=8624 uid=0 gid=0 # Default values
local device_type="c"
case "$device_path" in
*/snd/*)
major=116
minor=$((RANDOM % 20 + 1))
;; # ALSA devices
*/video*)
major=81
minor=$((RANDOM % 10))
;; # Video devices
*/input/*)
major=13
minor=$((RANDOM % 32))
;; # Input devices
*/dri/*)
major=226
minor=$((RANDOM % 10 + 128))
;; # DRI/GPU devices
*/ttyUSB*)
major=188
minor=$((RANDOM % 10))
;; # USB serial
*/dvb/*)
major=212
minor=$((RANDOM % 10))
;; # DVB devices
*/radio*)
major=81
minor=$((RANDOM % 10 + 64))
;; # Radio devices
*/tty[0-9]*)
major=4
minor=$((RANDOM % 10))
;; # TTY devices
*)
major=1
minor=$((RANDOM % 10))
;; # Fallback
esac
# Adjust gid for audio devices (usually audio group = 63)
if [[ "$device_path" == */snd/* ]]; then
gid=63
fi
echo "$device_type:$major:$minor:$file_mode:$uid:$gid"
return 0
fi
fi
# Neither real device nor mock - fail
return 1
}
# Check if device directory exists or we have mock devices for the type
should_process_device_type() {
local device_type="$1"
local directory="$2"
local override_var="TEST_MOCK_${device_type^^}_DEVICES"
local override_value="${!override_var:-}"
# Process if we have mock devices OR the directory exists and is accessible
[[ -n "$override_value" ]] || [[ -d "$directory" ]] 2>/dev/null
}
# GPU device discovery with mocking support for wayland-client-devices
discover_gpu_devices() {
if [[ -n "${TEST_MOCK_GPU_DEVICES:-}" ]]; then
# Use test-provided mock devices
log "DEBUG" "Using mock GPU devices: $TEST_MOCK_GPU_DEVICES"
echo "$TEST_MOCK_GPU_DEVICES" | tr ',' ' '
else
# Normal device discovery - check if directory exists first
if [[ -d "/dev/dri" ]]; then
find /dev/dri -type c \( -regex ".*/render.*" \) 2>/dev/null || true
else
# No GPU devices directory - return empty (not an error)
log "DEBUG" "No /dev/dri directory found - no GPU devices available"
return 0
fi
fi
}

View File

@ -4,9 +4,136 @@ The QM Device Manager OCI Hook provides dynamic device access management for QM
## Overview
The device manager hook allows containers to request specific device access through annotations. The hook dynamically discovers and mounts the requested devices at container creation time.
The device manager hook allows containers to request specific device access through annotations. The hook dynamically discovers and mounts the requested devices at container creation time. It supports both device categories and Wayland seat-based device access.
## Supported Devices
## How Device Injection Works
The QM Device Manager hook operates during the OCI container **precreate** phase, intercepting container creation to dynamically inject device access. When QM starts a container with device annotations, the hook:
1. Discovers available devices on the host system based on annotation patterns
2. Validates device accessibility and permissions
3. Injects device access into the container's OCI specification via `linux.resources.devices[]`
4. Adds device mounts to make devices available inside the container
### OCI Device Injection Format
The hook modifies the container's OCI runtime specification to include device access rules:
```json
{
"linux": {
"resources": {
"devices": [
{
"allow": true,
"type": "c",
"major": 116,
"minor": 0,
"access": "rwm"
},
{
"allow": true,
"type": "c",
"major": 116,
"minor": 1,
"access": "rwm"
}
]
}
},
"mounts": [
{
"source": "/dev/snd/controlC0",
"destination": "/dev/snd/controlC0",
"type": "bind",
"options": ["bind", "rprivate"]
}
]
}
```
This ensures the container has both cgroup device access permissions and the actual device nodes mounted.
### Testing and Verification
The device injection process is comprehensively tested in the test suite:
- Device Discovery Tests (`test_qm_devices.py`): Verifies correct device discovery for all supported types
- Annotation Processing (`test_qm_validation.py`): Tests annotation parsing and validation
- Mock Device Support (`test_utils.py`): Provides mock device infrastructure for CI testing
- Integration Tests (`test_qm_performance.py`): End-to-end hook execution testing
Run tests to verify device injection:
```bash
cd oci-hooks && tox -e unit -- -k "device"
```
### Library Architecture
The QM Device Manager hook uses a modular library architecture for maintainability and testing:
#### Core Libraries
- `common.sh`: Shared logging and utility functions used across all OCI hooks
- `device-support.sh`: Production device discovery and management functions
- `mock-device-support.sh`: Testing-specific device simulation functions
#### Mock Device Support for Testing
The `mock-device-support.sh` library enables comprehensive testing without requiring actual hardware devices. It works by:
Environment Variable Control - Tests set environment variables like:
```bash
TEST_MOCK_AUDIO_DEVICES="/tmp/test_devices/snd/controlC0,/tmp/test_devices/snd/pcmC0D0p"
TEST_MOCK_VIDEO_DEVICES="/tmp/test_devices/video0,/tmp/test_devices/video1"
TEST_LOGFILE="/tmp/test.log" # Enables test mode
```
Mock Device Creation - Python test framework creates temporary device files:
```python
# In test_utils.py DeviceDetector class
device_path = Path("/tmp/test_devices/snd/controlC0")
device_path.parent.mkdir(parents=True, exist_ok=True)
device_path.touch() # Creates regular file as mock device
```
Hook Library Selection - The hook script automatically detects test mode and sources the appropriate library:
```bash
# In oci-qm-device-manager script
if [[ -n "${TEST_LOGFILE:-}" ]]; then
source /usr/libexec/oci/lib/mock-device-support.sh
else
source /usr/libexec/oci/lib/device-support.sh
fi
```
**NOTE**: mock device library is not supported in a productive environment.
Mock Functions - `mock-device-support.sh` overrides discovery functions to return test devices:
```bash
discover_audio_devices() {
if [[ -n "${TEST_MOCK_AUDIO_DEVICES:-}" ]]; then
echo "${TEST_MOCK_AUDIO_DEVICES}" | tr ',' '\n'
fi
}
get_device_info() {
local device_path="$1"
# Returns mock device info for regular files in test mode
echo "c 116:0" # Mock major:minor for audio devices
}
```
This architecture allows the same hook script to work with both real hardware devices in production and simulated devices in CI testing environments.
## Supported Device Types
### Traditional Device Categories
| Device Type | Annotation | Devices Mounted | Description |
|-------------|------------|-----------------|-------------|
@ -18,6 +145,14 @@ The device manager hook allows containers to request specific device access thro
| dvb | `org.containers.qm.device.dvb=true` | `/dev/dvb/*` | DVB digital TV devices |
| radio | `org.containers.qm.device.radio=true` | `/dev/radio*` | Radio devices |
### Wayland Seat Support
| Annotation | Purpose | Description |
|------------|---------|-------------|
| `org.containers.qm.wayland.seat=<seat_name>` | Multi-seat support | Mounts devices associated with a specific Wayland seat (e.g., `seat0`) |
The Wayland seat functionality dynamically discovers and mounts devices associated with the specified seat using `loginctl seat-status`. This enables proper multi-seat support for Wayland environments where different users may be logged into different seats.
## Usage Examples
### Systemd Drop-In Files
@ -34,27 +169,65 @@ The device manager hook allows containers to request specific device access thro
[Container]
Annotation=org.containers.qm.device.audio=true
Annotation=org.containers.qm.device.ttys=true
# Wayland seat support
Annotation=org.containers.qm.wayland.seat=seat0
```
### Podman Command Line
### Generated Systemd Service
When using Quadlet (`.container` files), the systemd generator creates the actual `ExecStart` commands that execute `podman run`. You can preview these with:
```bash
# Run container with audio device access
podman run --annotation org.containers.qm.device.audio=true qm
# View generated systemd service commands
/usr/lib/systemd/system-generators/podman-system-generator --dryrun
# Run container with all TTY devices (for window managers)
podman run --annotation org.containers.qm.device.ttys=true qm
# Run container with all USB TTY devices (for serial communication)
podman run --annotation org.containers.qm.device.ttyUSB=true qm
# Run container with multiple device types
podman run \
--annotation org.containers.qm.device.audio=true \
--annotation org.containers.qm.device.video=true \
qm
# Check generated service files
ls -la /var/lib/systemd/generated/
cat /var/lib/systemd/generated/container-name.service
```
#### Example Generated Commands within QM
For a Quadlet container with device annotations:
Quadlet File (`/etc/containers/systemd/audio-app.container`):
```ini
[Unit]
Description=Audio Application Container
After=local-fs.target
[Container]
Image=my-audio-app:latest
Annotation=org.containers.qm.device.audio=true
Annotation=org.containers.qm.device.video=true
Exec=sleep infinity
[Install]
WantedBy=default.target
```
Generated ExecStart Command:
```bash
ExecStart=/usr/bin/podman run \
--cidfile=%t/%N.cid \
--cgroups=split \
--replace \
--rm -d \
--sdnotify=container \
--name=audio-app \
--annotation=org.containers.qm.device.audio=true \
--annotation=org.containers.qm.device.video=true \
localhost/qm/my-audio-app:latest sleep infinity
```
When this service starts, the `qm-device-manager` hook intercepts the container creation and dynamically adds device mounts like:
- `--device=/dev/snd/controlC0:/dev/snd/controlC0`
- `--device=/dev/snd/pcmC0D0p:/dev/snd/pcmC0D0p`
- `--device=/dev/video0:/dev/video0`
## Logging
The hook logs activity to `/var/log/qm-device-manager.log` for debugging and monitoring:
@ -65,6 +238,9 @@ tail -f /var/log/qm-device-manager.log
# Check device discovery for a specific container
grep "audio" /var/log/qm-device-manager.log
# Check Wayland seat processing
grep "Wayland seat" /var/log/qm-device-manager.log
```
## Security Considerations
@ -73,6 +249,7 @@ grep "audio" /var/log/qm-device-manager.log
- Device permissions are preserved from the host
- Hook validates device accessibility before mounting
- Annotation-based activation prevents accidental device exposure
- Wayland seat integration respects systemd-logind seat assignments
## Troubleshooting
@ -85,6 +262,15 @@ If a requested device is not mounted:
3. Check hook logs: `grep ERROR /var/log/qm-device-manager.log`
4. Ensure the device is accessible: `test -c /dev/snd/controlC0`
### Wayland Seat Issues
If Wayland seat devices are not mounted:
1. Check seat status: `loginctl seat-status seat0`
2. Verify seat annotation: `org.containers.qm.wayland.seat=seat0`
3. Check systemd-logind service: `systemctl status systemd-logind`
4. Review seat logs: `grep "seat0" /var/log/qm-device-manager.log`
### Hook Not Triggering
If the hook is not being called:

View File

@ -28,74 +28,94 @@
# - dvb: /dev/dvb/* (DVB digital TV devices)
# - radio: /dev/radio* (radio devices)
#
# Usage via annotations:
# - org.containers.qm.device.audio=true
# - org.containers.qm.device.video=true
# - org.containers.qm.device.input=true
# - org.containers.qm.device.ttys=true # Mount all TTY devices (tty0-7)
# - org.containers.qm.device.ttyUSB=true # Mount all USB TTY devices (ttyUSB*)
# - org.containers.qm.device.dvb=true
# - org.containers.qm.device.radio=true
# Supported device annotations:
# - org.containers.qm.device.audio=true # /dev/snd/* (ALSA sound devices)
# - org.containers.qm.device.video=true # /dev/video*, /dev/media* (V4L2 video devices)
# - org.containers.qm.device.input=true # /dev/input/* (input devices)
# - org.containers.qm.device.ttys=true # /dev/tty0-7 (virtual TTY devices)
# - org.containers.qm.device.ttyUSB=true # /dev/ttyUSB* (USB TTY devices)
# - org.containers.qm.device.dvb=true # /dev/dvb/* (DVB digital TV devices)
# - org.containers.qm.device.radio=true # /dev/radio* (radio devices)
#
# Supported Wayland annotations:
# - org.containers.qm.wayland.seat=<seat_name> # Devices for specific systemd-logind seat
set -euo pipefail
# Source common utilities and appropriate device support library
SCRIPT_DIR="$(dirname "${BASH_SOURCE[0]}")"
# shellcheck source=../lib/common.sh disable=SC1091
source "${SCRIPT_DIR}/../lib/common.sh"
if [[ -n "${TEST_LOGFILE:-}" ]]; then
# Test mode - use mock device support
# shellcheck source=../lib/mock-device-support.sh disable=SC1091
source "${SCRIPT_DIR}/../lib/mock-device-support.sh"
else
# Normal mode - use standard device support
# shellcheck source=../lib/device-support.sh disable=SC1091
source "${SCRIPT_DIR}/../lib/device-support.sh"
fi
# Configuration
LOGFILE="/var/log/qm-device-manager.log"
LOGFILE="${TEST_LOGFILE:-/var/log/qm-device-manager.log}"
# shellcheck disable=SC2034 # Used by log() function in device-support.sh
HOOK_NAME="qm-device-manager"
# Logging function
log() {
local level="$1"
shift
echo "$(date '+%Y-%m-%d %H:%M:%S') - $HOOK_NAME - $level - $*" >> "$LOGFILE"
if [[ "$level" == "ERROR" ]]; then
echo "$(date '+%Y-%m-%d %H:%M:%S') - $HOOK_NAME - $level - $*" >&2
fi
}
# Process input devices with optional filtering and different output modes
process_input_devices() {
local mode="$1" # "direct" or "collect"
local spec_json="$2" # current spec (for direct mode)
local devname_list_var="$3" # variable name for device list (for collect mode)
local filter_pattern="${4:-}" # optional regex filter
local log_prefix="${5:-input}" # log message prefix
# Check if device exists and is a character/block device
is_device_accessible() {
local device_path="$1"
[[ -c "$device_path" || -b "$device_path" ]]
}
local device_count=0
# Get device information for OCI spec
get_device_info() {
local device_path="$1"
local stat_output
if ! stat_output=$(stat -c "%f %t %T %a %u %g" "$device_path" 2>/dev/null); then
return 1
if [[ ! -d "/dev/input" && -z "${TEST_LOGFILE:-}" ]]; then
if [[ "$mode" == "direct" ]]; then
log "INFO" "Added $device_count $log_prefix devices (no /dev/input directory)"
echo "$spec_json"
fi
return 0
fi
read -r mode_hex major_hex minor_hex perms uid gid <<< "$stat_output"
# Convert hex to decimal
local mode_dec=$((0x$mode_hex))
local major_dec=$((0x$major_hex))
local minor_dec=$((0x$minor_hex))
# Determine device type (character or block)
local device_type
if [[ $((mode_dec & 0x2000)) -ne 0 ]]; then
device_type="c" # Character device
elif [[ $((mode_dec & 0x6000)) -ne 0 ]]; then
device_type="b" # Block device
else
return 1 # Not a device
if [[ "$mode" == "collect" ]]; then
# For collect mode, we need to use nameref to modify the array
local -n devname_list_ref="$devname_list_var"
fi
# Convert permissions to decimal
local file_mode=$((0$perms))
while IFS= read -r -d '' device_path; do
# Apply filter if provided
if [[ -n "$filter_pattern" ]] && [[ ! "$device_path" =~ $filter_pattern ]]; then
continue
fi
echo "$device_type $major_dec $minor_dec $file_mode $uid $gid"
if [[ "$mode" == "direct" ]]; then
# Add device directly to spec
spec_json=$(add_device_to_spec "$spec_json" "$device_path")
((device_count++))
elif [[ "$mode" == "collect" ]]; then
# Add device to collection list
devname_list_ref+=("$device_path")
log "INFO" "Adding $log_prefix device: $device_path"
((device_count++))
fi
done < <(discover_devices "find /dev/input -type c -print0" "input")
if [[ "$mode" == "direct" ]]; then
log "INFO" "Added $device_count $log_prefix devices"
echo "$spec_json"
fi
}
# Add device to OCI spec using jq
add_device_to_spec() {
local spec_json="$1"
local device_path="$2"
local device_info
local add_resources="${3:-false}"
local device_info major minor file_mode uid gid device_type
if ! device_info=$(get_device_info "$device_path"); then
log "WARNING" "Failed to get device info for $device_path"
@ -103,8 +123,7 @@ add_device_to_spec() {
return
fi
read -r device_type major minor file_mode uid gid <<< "$device_info"
IFS=':' read -r device_type major minor file_mode uid gid <<<"$device_info"
log "INFO" "Adding device: $device_path (type=$device_type, major=$major, minor=$minor)"
# Ensure .linux.devices array exists
@ -118,105 +137,171 @@ add_device_to_spec() {
# Add device if it doesn't already exist
local result
if ! result=$(echo "$temp_spec" | jq --compact-output \
--arg path "$device_path" \
--arg type "$device_type" \
--argjson major "$major" \
--argjson minor "$minor" \
--argjson fileMode "$file_mode" \
--argjson uid "$uid" \
--argjson gid "$gid" \
'if (.linux.devices | map(.path) | index($path)) == null then .linux.devices += [{"path": $path, "type": $type, "major": $major, "minor": $minor, "fileMode": $fileMode, "uid": $uid, "gid": $gid}] else . end' 2>/dev/null); then
--arg path "$device_path" \
--arg type "$device_type" \
--argjson major "$major" \
--argjson minor "$minor" \
--argjson fileMode "$file_mode" \
--argjson uid "$uid" \
--argjson gid "$gid" \
'if (.linux.devices | map(.path) | index($path)) == null then .linux.devices += [{"path": $path, "type": $type, "major": $major, "minor": $minor, "fileMode": $fileMode, "uid": $uid, "gid": $gid}] else . end' 2>/dev/null); then
log "ERROR" "Failed to add device $device_path to spec"
echo "$temp_spec" # Return the spec with array at least initialized
echo "$temp_spec" # Return the spec with array at least initialized
return
fi
# Add device resources if requested (for Wayland devices)
if [[ "$add_resources" == "true" ]]; then
if ! result=$(echo "$result" | jq --compact-output \
--arg type "$device_type" \
--argjson major "$major" \
--argjson minor "$minor" \
'if .linux.resources == null then .linux.resources = {} else . end |
if .linux.resources.devices == null then .linux.resources.devices = [] else . end |
if (.linux.resources.devices | map(select(.type == $type and .major == $major and .minor == $minor)) | length) == 0 then
.linux.resources.devices += [{"allow": true, "type": $type, "major": $major, "minor": $minor, "access": "rwm"}]
else . end' 2>/dev/null); then
log "WARNING" "Failed to add device resources for $device_path, continuing with device only"
else
log "INFO" "Added device resources for: $device_path"
fi
fi
echo "$result"
}
# Expand device patterns to actual device paths
expand_device_patterns() {
local patterns=("$@")
local devices=()
for pattern in "${patterns[@]}"; do
if [[ "$pattern" == *"*"* ]]; then
# Pattern with wildcards - use shell globbing
# Disable nomatch option temporarily to handle no matches gracefully
set +o nomatch 2>/dev/null || true
for match in $pattern; do
if [[ -e "$match" ]]; then
devices+=("$match")
fi
done
set -o nomatch 2>/dev/null || true
else
# Exact path
if [[ -e "$pattern" ]]; then
devices+=("$pattern")
fi
fi
done
printf '%s\n' "${devices[@]}"
}
# Process device annotations and add devices to spec
# Process device annotations (org.containers.qm.device.*)
process_device_annotation() {
local spec_json="$1"
local device_type="$2"
local device_value="${3:-true}"
local patterns
local devices
local device_count=0
log "INFO" "Processing device type: $device_type"
case "$device_type" in
audio)
patterns=("/dev/snd/"*)
;;
video)
patterns=("/dev/video"* "/dev/media"*)
;;
input)
patterns=("/dev/input/"*)
;;
ttys)
# Mount all virtual TTY devices (tty0-7)
patterns=("/dev/tty0" "/dev/tty1" "/dev/tty2" "/dev/tty3" "/dev/tty4" "/dev/tty5" "/dev/tty6" "/dev/tty7")
log "INFO" "Mounting all virtual TTY devices (tty0-7)"
;;
ttyUSB)
# Mount all USB TTY devices (ttyUSB*)
patterns=("/dev/ttyUSB"*)
log "INFO" "Mounting all USB TTY devices (ttyUSB*)"
;;
dvb)
patterns=("/dev/dvb/"*)
;;
radio)
patterns=("/dev/radio"*)
;;
*)
log "WARNING" "Unknown device type: $device_type"
echo "$spec_json"
return
;;
"audio")
# ALSA sound devices
local device_count=0
if should_process_device_type "audio" "/dev/snd"; then
while IFS= read -r -d '' device_path; do
spec_json=$(add_device_to_spec "$spec_json" "$device_path")
((device_count++))
done < <(discover_devices "find /dev/snd -type c -print0" "audio")
fi
log "INFO" "Added $device_count audio devices"
;;
"video")
# V4L2 video devices
local device_count=0
while IFS= read -r -d '' device_path; do
spec_json=$(add_device_to_spec "$spec_json" "$device_path")
((device_count++))
done < <(discover_devices "find /dev -maxdepth 1 \\( -name \"video*\" -o -name \"media*\" \\) -type c -print0" "video")
log "INFO" "Added $device_count video devices"
;;
"input")
# Input devices
spec_json=$(process_input_devices "direct" "$spec_json" "" "" "input")
;;
"ttys")
# Virtual TTY devices (tty0-7)
local device_count=0
while IFS= read -r -d '' device_path; do
spec_json=$(add_device_to_spec "$spec_json" "$device_path")
((device_count++))
done < <(discover_devices "find /dev -maxdepth 1 -name 'tty[0-7]' -type c -print0" "ttys")
log "INFO" "Added $device_count TTY devices"
;;
"ttyUSB")
# USB TTY devices
local device_count=0
while IFS= read -r -d '' device_path; do
spec_json=$(add_device_to_spec "$spec_json" "$device_path")
((device_count++))
done < <(discover_devices "find /dev -maxdepth 1 -name \"ttyUSB*\" -type c -print0" "ttyUSB")
log "INFO" "Added $device_count USB TTY devices"
;;
"dvb")
# DVB digital TV devices
local device_count=0
if should_process_device_type "dvb" "/dev/dvb"; then
while IFS= read -r -d '' device_path; do
spec_json=$(add_device_to_spec "$spec_json" "$device_path")
((device_count++))
done < <(discover_devices "find /dev/dvb -type c -print0" "dvb")
fi
log "INFO" "Added $device_count DVB devices"
;;
"radio")
# Radio devices
local device_count=0
while IFS= read -r -d '' device_path; do
spec_json=$(add_device_to_spec "$spec_json" "$device_path")
((device_count++))
done < <(discover_devices "find /dev -maxdepth 1 -name \"radio*\" -type c -print0" "radio")
log "INFO" "Added $device_count radio devices"
;;
*)
log "WARNING" "Unknown device type: $device_type"
;;
esac
log "INFO" "Enabling $device_type devices"
echo "$spec_json"
}
# Get devices matching patterns
mapfile -t devices < <(expand_device_patterns "${patterns[@]}")
# Process Wayland seat annotation (org.containers.qm.wayland.seat)
process_wayland_seat() {
local spec_json="$1"
local seat_name="$2"
# Add each accessible device
for device in "${devices[@]}"; do
if is_device_accessible "$device"; then
spec_json=$(add_device_to_spec "$spec_json" "$device")
((device_count++))
log "INFO" "Processing Wayland seat: $seat_name"
local device_count=0
local devname_list=()
# Get devices associated with the systemd-logind seat
if command -v loginctl >/dev/null 2>&1; then
local seat_devices
if seat_devices=$(loginctl seat-status "$seat_name" 2>/dev/null | grep -oP '/sys\S+'); then
log "INFO" "Found seat system devices for $seat_name"
while IFS= read -r device; do
if [[ -n "$device" ]]; then
local devname
if devname=$(udevadm info -x "$device" 2>/dev/null | grep -oP '^E: DEVNAME=\K.*'); then
if [[ -n "$devname" && -e "$devname" ]]; then
devname_list+=("$devname")
log "INFO" "Found seat device: $devname"
fi
fi
fi
done <<<"$seat_devices"
else
log "WARNING" "No devices found for seat $seat_name or seat does not exist"
fi
else
log "WARNING" "loginctl not available, cannot query seat devices"
fi
# Add common input devices
process_input_devices "collect" "" "devname_list" "/dev/input/(event[0-9]+|mice[0-9]*|mouse[0-9]+)$" "input"
# Add GPU render devices
if [[ -d "/dev/dri" ]]; then
while IFS= read -r -d '' device_path; do
if [[ "$device_path" =~ /dev/dri/render.* ]]; then
devname_list+=("$device_path")
log "INFO" "Adding render device: $device_path"
fi
done < <(discover_devices "find /dev/dri -type c -name \"render*\" -print0" "gpu")
fi
# Add all devices to spec with resources
for device_path in "${devname_list[@]}"; do
spec_json=$(add_device_to_spec "$spec_json" "$device_path" "true")
((device_count++))
done
log "INFO" "Found $device_count devices for $device_type"
log "INFO" "Added $device_count Wayland seat devices for $seat_name"
echo "$spec_json"
}
@ -237,8 +322,8 @@ main() {
spec_json=$(echo "$spec_json" | jq '.linux = {}')
fi
# Get annotations (both boolean and string values)
annotations=$(echo "$spec_json" | jq -r '.annotations // {} | to_entries[] | select(.key | startswith("org.containers.qm.device.")) | "\(.key)=\(.value)"' 2>/dev/null || true)
# Get all QM-related annotations
annotations=$(echo "$spec_json" | jq -r '.annotations // {} | to_entries[] | select(.key | startswith("org.containers.qm.")) | "\(.key)=\(.value)"' 2>/dev/null || true)
if [[ -z "$annotations" ]]; then
log "INFO" "No QM device annotations found"
@ -254,23 +339,39 @@ main() {
continue
fi
# Extract device key and value
device_key="${annotation%%=*}"
device_value="${annotation#*=}"
device_type="${device_key#org.containers.qm.device.}"
# Extract annotation key and value
annotation_key="${annotation%%=*}"
annotation_value="${annotation#*=}"
# Skip if value is not true/1/yes
if [[ ! "$device_value" =~ ^(true|1|yes)$ ]]; then
log "INFO" "Skipping annotation with invalid value: $annotation"
continue
fi
log "INFO" "Processing annotation: $annotation"
log "INFO" "Processing annotation: $annotation (device_type: $device_type, value: $device_value)"
case "$annotation_key" in
"org.containers.qm.device."*)
# Traditional device annotation
device_type="${annotation_key#org.containers.qm.device.}"
# Process the device type
spec_json=$(process_device_annotation "$spec_json" "$device_type" "$device_value")
# Skip if value is not true/1/yes
if [[ ! "$annotation_value" =~ ^(true|1|yes)$ ]]; then
log "INFO" "Skipping device annotation with invalid value: $annotation"
continue
fi
done <<< "$annotations"
spec_json=$(process_device_annotation "$spec_json" "$device_type" "$annotation_value")
;;
"org.containers.qm.wayland.seat")
# Wayland seat annotation
if [[ -n "$annotation_value" && "$annotation_value" != "null" ]]; then
spec_json=$(process_wayland_seat "$spec_json" "$annotation_value")
else
log "INFO" "Skipping Wayland seat annotation with empty value"
fi
;;
*)
log "INFO" "Skipping unknown QM annotation: $annotation_key"
;;
esac
done <<<"$annotations"
# Count total devices added
total_devices=$(echo "$spec_json" | jq '.linux.devices // [] | length' 2>/dev/null || echo "0")

View File

@ -1,12 +1,13 @@
{
"version": "1.0.0",
"hook": {
"path": "/usr/libexec/oci/hooks.d/oci-qm-device-manager"
},
"when": {
"annotations": {
"^org\\.containers\\.qm\\.device\\..*$": "^(true|1|yes)$"
}
},
"stages": ["precreate"]
"version": "1.0.0",
"hook": {
"path": "/usr/libexec/oci/hooks.d/oci-qm-device-manager"
},
"when": {
"annotations": {
"^org\\.containers\\.qm\\.device\\..*$": "^.*$",
"^org\\.containers\\.qm\\.wayland\\.seat": "^.*$"
}
},
"stages": ["precreate"]
}

68
oci-hooks/tests/.gitignore vendored Normal file
View File

@ -0,0 +1,68 @@
# Virtual environments
venv/
env/
.venv/
.env/
# Tox environments and cache
.tox/
.tox-*/
# Test reports and artifacts
reports/
.pytest_cache/
.coverage
htmlcov/
.nyc_output/
# Python cache and bytecode
__pycache__/
*.py[cod]
*$py.class
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# IDEs and editors
.vscode/
.idea/
*.swp
*.swo
*~
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Temporary files
*.tmp
*.temp
/tmp/
.tmp/
# Logs
*.log
logs/

50
oci-hooks/tests/.pylintrc Normal file
View File

@ -0,0 +1,50 @@
[MAIN]
# Specify a score threshold to be exceeded before program exits with error
fail-under=10.0
# Use multiple processes to speed up Pylint
jobs=1
# Allow loading of arbitrary C extensions
unsafe-load-any-extension=no
[MESSAGES CONTROL]
# Disable specific warnings that are common/acceptable in test files
disable=
# Import related (pytest/test dependencies are OK)
import-error,
# Test-specific patterns
unused-argument, # Fixtures often have unused parameters
redefined-outer-name, # Fixture names often match test parameters
unused-variable, # Test outputs not always used
inconsistent-return-statements, # pytest.fail() is intentional pattern
# Less critical for tests
too-few-public-methods,
too-many-arguments,
too-many-locals,
duplicate-code, # Some duplication in tests is acceptable
# File organization
wrong-import-order, # Less critical in test files
# Naming
invalid-name, # Test method names can be long and descriptive
[DESIGN]
# Maximum number of arguments for function / method
max-args=8
# Maximum number of locals for function / method body
max-locals=20
[SIMILARITIES]
# Minimum lines number of a similarity
min-similarity-lines=10
[BASIC]
# Naming style matching correct function names (relaxed for tests)
function-rgx=[a-z_][a-z0-9_]{2,50}$
# Naming style matching correct method names (relaxed for tests)
method-rgx=[a-z_][a-z0-9_]{2,50}$
# Naming style matching correct class names
class-rgx=[A-Z_][a-zA-Z0-9]+$

107
oci-hooks/tests/README.md Normal file
View File

@ -0,0 +1,107 @@
# OCI Hooks Unit Tests
Simple unit tests for QM OCI hooks using pytest and tox.
## Quick Start
```bash
# Set up virtual environment
cd oci-hooks
python -m venv .venv
source .venv/bin/activate # Linux
# Install dependencies
pip install -r requirements.txt
# Install tox
pip install tox
# Run unit tests
tox -e unit
```
## Test Structure
### Test Files
- `test_qm_device_manager.py` - Tests for the unified device manager hook
- `test_wayland_client_devices.py` - Tests for the Wayland client GPU hook
### Test Categories
- **Unit tests**: Fast tests with no external dependencies
- **Integration tests**: Tests that may require system resources
- **Performance tests**: Tests with timing requirements
## Running Tests
### Using Tox
```bash
tox -e all # Run all tests
tox -e lint # Check code formatting for python and shell
tox -e format # Auto-format code with black
```
### Direct Pytest
If you prefer to avoid tox, you can run pytest directly:
```bash
# Set up virtual environment
python -m venv .venv
source .venv/bin/activate # Linux
# Install dependencies
pip install -r requirements.txt
# Run tests
pytest -m unit --tb=short -v # Unit tests only
pytest test_qm_device_manager.py # Specific test file
```
## CI/CD Integration
Tests run automatically in GitHub Actions on:
- Push to `oci-hooks/**` paths
- Pull requests affecting `oci-hooks/**` paths
Matrix testing across Python versions 3.9-3.12.
## Development Workflow
1. **Make changes** to OCI hook scripts or tests
2. **Run tests locally**: `tox -e unit`
3. **Check code quality**: `tox -e lint`
4. **Auto-format if needed**: `tox -e format`
5. **Fix any failures** before committing
6. **Push changes** - CI will run full test suite
## Troubleshooting
### Permission Issues
If hooks fail with permission errors, ensure scripts are executable:
```bash
chmod +x ../qm-device-manager/oci-qm-device-manager
chmod +x ../wayland-client-devices/oci-qm-wayland-client-devices
```
### Missing Dependencies
```bash
# For tox
pip install tox
# For direct pytest
pip install -r requirements.txt
```
### Test Environment
Tests use temporary log files to avoid permission issues:
- Set `TEST_LOGFILE` environment variable for custom log location
- Default: `/tmp/test-oci-hooks.log` for test runs

View File

@ -0,0 +1,98 @@
"""Pytest configuration and fixtures for OCI hooks testing."""
import os
import tempfile
from pathlib import Path
import pytest
from device_utils import DeviceCounter, TempDevices
from test_utils import (
HookRunner,
OciSpecValidator,
LogChecker,
)
@pytest.fixture(scope="session")
def test_env():
"""Set up test environment with temporary log files."""
with tempfile.TemporaryDirectory(prefix="oci_hooks_test_") as temp_dir:
env = os.environ.copy()
env["TEST_LOGFILE"] = os.path.join(temp_dir, "test-hook.log")
yield {
"env": env,
"log_file": env["TEST_LOGFILE"],
"temp_dir": temp_dir,
}
@pytest.fixture
def hook_runner(test_env):
"""Create a HookRunner instance with test environment and hook paths."""
yield HookRunner(test_env["env"])
@pytest.fixture
def oci_spec_validator():
"""Provide validator for OCI specification JSON."""
return OciSpecValidator.validate
@pytest.fixture
def sample_specs():
"""Sample OCI specifications for testing."""
yield {
"audio_device": {
"annotations": {"org.containers.qm.device.audio": "true"},
"linux": {},
},
"multiple_devices": {
"annotations": {
"org.containers.qm.device.audio": "true",
"org.containers.qm.device.input": "true",
"org.containers.qm.device.ttys": "true",
},
"linux": {},
},
"wayland_seat": {
"annotations": {"org.containers.qm.wayland.seat": "seat0"},
"linux": {},
},
"wayland_gpu": {
"annotations": {"org.containers.qm.wayland-client.gpu": "true"},
"linux": {},
},
"combined": {
"annotations": {
"org.containers.qm.device.audio": "true",
"org.containers.qm.wayland.seat": "seat0",
},
"linux": {},
},
"invalid_device": {
"annotations": {"org.containers.qm.device.audio": "false"},
"linux": {},
},
}
@pytest.fixture
def log_checker(test_env):
"""Check hook log files for expected content."""
return LogChecker(test_env["log_file"])
@pytest.fixture
def device_counter():
"""Fixture providing device counting utilities."""
return DeviceCounter
@pytest.fixture
def temp_devices():
"""Fixture providing on-demand temporary device creation."""
with tempfile.TemporaryDirectory(prefix="test_devices_") as temp_dir:
temp_path = Path(temp_dir)
yield TempDevices(temp_path)

View File

@ -0,0 +1,223 @@
"""Device testing utilities for OCI hooks."""
import os
import stat
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, Any, Optional, List
class DeviceCounter:
"""Helper class for counting devices in OCI specifications."""
@staticmethod
def count_devices(
spec: Dict[str, Any], device_pattern: Optional[str] = None
) -> int:
"""Count devices in OCI spec, optionally filtered by path pattern."""
devices = spec.get("linux", {}).get("devices", [])
if device_pattern is None:
return len(devices)
return len([d for d in devices if device_pattern in d.get("path", "")])
@staticmethod
def count_resources(spec: Dict[str, Any]) -> int:
"""Count device resources in OCI spec."""
resources = (
spec.get("linux", {}).get("resources", {}).get("devices", [])
)
return len(resources)
@staticmethod
def has_device_path(spec: Dict[str, Any], path: str) -> bool:
"""Check if OCI spec contains a device with specific path."""
devices = spec.get("linux", {}).get("devices", [])
return any(d.get("path") == path for d in devices)
@dataclass
class DeviceDetector:
"""Class for detecting real devices for testing."""
paths: List[str] = field(default_factory=list)
expected_count: int = 0
def mock_devices(self, device_type) -> Optional[Dict[str, str]]:
"""Mock devices for testing."""
return {device_type: ",".join(self.paths)}
class TempDevices:
"""Class for creating temporary device files on-demand for testing."""
def __init__(self, temp_path: Path):
"""Initialize TempDevices with temporary path."""
self.temp_path = temp_path
self._created_devices = {}
# Create base directory structure
for subdir in ["snd", "dri", "input", "dvb"]:
(self.temp_path / subdir).mkdir(exist_ok=True)
def _create_device(
self, device_path: Path, major: int, minor: int
) -> Path:
"""Create device node with mknod if possible, fall back to env vars."""
device_path.parent.mkdir(parents=True, exist_ok=True)
if os.environ.get("FORCE_MOCK_DEVICES", "false").lower() != "true":
try:
# Try to create real device nodes when not forced
os.mknod(
device_path, stat.S_IFCHR | 0o600, os.makedev(major, minor)
)
except OSError:
# Fallback to regular files if mknod fails
device_path.touch()
else:
# Create regular files when forced to use mock mode
device_path.touch()
return device_path
def _set_mock_env_for_devices(self, device_type: str, devices: List[Path]):
"""Set mock environment variables for device discovery."""
env_var = f"TEST_MOCK_{device_type.upper()}_DEVICES"
os.environ[env_var] = ",".join(str(d) for d in devices)
def get_audio_devices(self) -> DeviceDetector:
"""Get audio device paths for testing."""
if "audio" not in self._created_devices:
devices = [
self._create_device(
self.temp_path / "snd" / "controlC0", 116, 0
),
self._create_device(
self.temp_path / "snd" / "pcmC0D0p", 116, 24
),
]
self._created_devices["audio"] = devices
self._set_mock_env_for_devices("audio", devices)
device_paths = [str(d) for d in self._created_devices["audio"]]
return DeviceDetector(
paths=device_paths, expected_count=len(device_paths)
)
def get_video_devices(self) -> DeviceDetector:
"""Get video device paths for testing."""
if "video" not in self._created_devices:
devices = [
self._create_device(self.temp_path / "video0", 81, 0),
self._create_device(self.temp_path / "video1", 81, 1),
]
self._created_devices["video"] = devices
self._set_mock_env_for_devices("video", devices)
device_paths = [str(d) for d in self._created_devices["video"]]
return DeviceDetector(
paths=device_paths, expected_count=len(device_paths)
)
def get_input_devices(self) -> DeviceDetector:
"""Get input device paths for testing."""
if "input" not in self._created_devices:
devices = [
self._create_device(
self.temp_path / "input" / "event0", 13, 64
),
self._create_device(
self.temp_path / "input" / "event1", 13, 65
),
self._create_device(self.temp_path / "input" / "mice", 13, 63),
]
self._created_devices["input"] = devices
self._set_mock_env_for_devices("input", devices)
device_paths = [str(d) for d in self._created_devices["input"]]
return DeviceDetector(
paths=device_paths, expected_count=len(device_paths)
)
def get_gpu_devices(self) -> DeviceDetector:
"""Get GPU device paths for testing."""
if "gpu" not in self._created_devices:
devices = [
self._create_device(
self.temp_path / "dri" / "renderD128", 226, 128
)
]
self._created_devices["gpu"] = devices
self._set_mock_env_for_devices("gpu", devices)
device_paths = [str(d) for d in self._created_devices["gpu"]]
return DeviceDetector(
paths=device_paths, expected_count=len(device_paths)
)
def get_dvb_devices(self) -> DeviceDetector:
"""Get DVB device paths for testing."""
if "dvb" not in self._created_devices:
devices = [
self._create_device(
self.temp_path / "dvb" / "adapter0" / "frontend0", 212, 4
),
self._create_device(
self.temp_path / "dvb" / "adapter0" / "demux0", 212, 5
),
]
self._created_devices["dvb"] = devices
self._set_mock_env_for_devices("dvb", devices)
device_paths = [str(d) for d in self._created_devices["dvb"]]
return DeviceDetector(
paths=device_paths, expected_count=len(device_paths)
)
def get_ttyusb_devices(self) -> DeviceDetector:
"""Get USB TTY device paths for testing."""
if "ttyUSB" not in self._created_devices:
devices = [
self._create_device(self.temp_path / "ttyUSB0", 188, 0),
self._create_device(self.temp_path / "ttyUSB1", 188, 1),
]
self._created_devices["ttyUSB"] = devices
self._set_mock_env_for_devices("ttyUSB", devices)
device_paths = [str(d) for d in self._created_devices["ttyUSB"]]
return DeviceDetector(
paths=device_paths, expected_count=len(device_paths)
)
def get_radio_devices(self) -> DeviceDetector:
"""Get radio device paths for testing."""
if "radio" not in self._created_devices:
devices = [
self._create_device(self.temp_path / "radio0", 81, 64),
self._create_device(self.temp_path / "radio1", 81, 65),
]
self._created_devices["radio"] = devices
self._set_mock_env_for_devices("radio", devices)
device_paths = [str(d) for d in self._created_devices["radio"]]
return DeviceDetector(
paths=device_paths, expected_count=len(device_paths)
)
def get_ttys_devices(self) -> DeviceDetector:
"""Get TTY device paths for testing."""
if "ttys" not in self._created_devices:
devices = [
self._create_device(self.temp_path / "tty0", 4, 0),
self._create_device(self.temp_path / "tty1", 4, 1),
self._create_device(self.temp_path / "tty2", 4, 2),
]
self._created_devices["ttys"] = devices
self._set_mock_env_for_devices("ttys", devices)
device_paths = [str(d) for d in self._created_devices["ttys"]]
return DeviceDetector(
paths=device_paths, expected_count=len(device_paths)
)

View File

@ -0,0 +1,24 @@
[pytest]
testpaths = .
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts =
--strict-markers
--verbose
--tb=short
markers =
unit: Unit tests that do not require external dependencies
integration: Integration tests that may require system resources
performance: Performance tests with timing requirements
slow: Tests that take more than 1 second to run
hook_execution: Tests that actually execute OCI hooks
json_validation: Tests that validate JSON structure
filterwarnings =
ignore::DeprecationWarning
ignore::PendingDeprecationWarning
log_cli = true
log_cli_level = INFO
log_cli_format = %(asctime)s [%(levelname)8s] %(name)s: %(message)s
log_cli_date_format = %Y-%m-%d %H:%M:%S
junit_family = xunit2

View File

@ -0,0 +1,5 @@
# Basic testing framework
pytest>=7.0.0
# Test fixtures and utilities
jsonschema>=4.0.0

View File

@ -0,0 +1,443 @@
#!/usr/bin/env python3
"""Tests for OCI hook configuration files."""
import json
import re
from pathlib import Path
from collections import Counter
import pytest
from test_utils import HookConfigLoader
TEST_SPEC = {
"version": "1.0.0",
"process": {
"terminal": False,
"user": {"uid": 0, "gid": 0},
"args": ["echo", "test"],
"env": [
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
],
"cwd": "/",
"capabilities": {
"bounding": [
"CAP_AUDIT_WRITE",
"CAP_KILL",
"CAP_NET_BIND_SERVICE",
],
"effective": [
"CAP_AUDIT_WRITE",
"CAP_KILL",
"CAP_NET_BIND_SERVICE",
],
"inheritable": [
"CAP_AUDIT_WRITE",
"CAP_KILL",
"CAP_NET_BIND_SERVICE",
],
"permitted": [
"CAP_AUDIT_WRITE",
"CAP_KILL",
"CAP_NET_BIND_SERVICE",
],
"ambient": [
"CAP_AUDIT_WRITE",
"CAP_KILL",
"CAP_NET_BIND_SERVICE",
],
},
"rlimits": [{"type": "RLIMIT_NOFILE", "hard": 1024, "soft": 1024}],
"noNewPrivileges": True,
},
"root": {"path": "rootfs", "readonly": True},
"hostname": "testing",
"mounts": [
{"destination": "/proc", "type": "proc", "source": "proc"},
{
"destination": "/dev",
"type": "tmpfs",
"source": "tmpfs",
"options": ["nosuid", "strictatime", "mode=755", "size=65536k"],
},
],
"annotations": {},
"linux": {
"resources": {},
"namespaces": [
{"type": "pid"},
{"type": "network"},
{"type": "ipc"},
{"type": "uts"},
{"type": "mount"},
],
"maskedPaths": [
"/proc/acpi",
"/proc/asound",
"/proc/kcore",
"/proc/keys",
"/proc/latency_stats",
"/proc/timer_list",
"/proc/timer_stats",
"/proc/sched_debug",
"/sys/firmware",
],
"readonlyPaths": [
"/proc/bus",
"/proc/fs",
"/proc/irq",
"/proc/sys",
"/proc/sysrq-trigger",
],
},
}
class TestHookConfigurations:
"""Test class for validating OCI hook JSON configurations."""
@pytest.fixture(scope="class")
def hook_configs(self):
"""Load all hook configuration files."""
return HookConfigLoader.load_all_hook_configs()
@staticmethod
def _get_hook_names():
"""Get hook names for parametrized tests."""
return HookConfigLoader.get_hook_names()
@pytest.mark.unit
@pytest.mark.parametrize("hook_name", _get_hook_names())
def test_json_syntax_validity(self, hook_configs, hook_name):
"""Test that hook JSON file has valid syntax."""
hook_data = hook_configs[hook_name]
config_path = hook_data["path"]
# Re-read and parse to ensure JSON is valid
try:
with open(config_path, "r", encoding="utf-8") as f:
json.load(f)
except json.JSONDecodeError as e:
pytest.fail(f"Invalid JSON syntax in {config_path}: {e}")
@pytest.mark.unit
@pytest.mark.parametrize("hook_name", _get_hook_names())
@pytest.mark.parametrize(
"required_field", ["version", "hook", "when", "stages"]
)
def test_required_schema_fields(
self, hook_configs, hook_name, required_field
):
"""Test that hook config has required OCI hook schema fields."""
hook_data = hook_configs[hook_name]
config = hook_data["config"]
config_path = hook_data["path"]
assert (
required_field in config
), f"Missing required field '{required_field}' in {config_path}"
@pytest.mark.unit
@pytest.mark.parametrize("hook_name", _get_hook_names())
def test_hook_path_field(self, hook_configs, hook_name):
"""Test that hook config has required OCI hook schema field 'path'."""
hook_data = hook_configs[hook_name]
hook_config = hook_data["config"].get("hook", {})
config_path = hook_data["path"]
assert (
"path" in hook_config
), f"Missing required hook field 'path' in {config_path}"
@pytest.mark.unit
@pytest.mark.parametrize("hook_name", _get_hook_names())
def test_version_format(self, hook_configs, hook_name):
"""Test that version field follows semantic versioning."""
semver_pattern = re.compile(r"^\d+\.\d+\.\d+$")
hook_data = hook_configs[hook_name]
config = hook_data["config"]
config_path = hook_data["path"]
version = config.get("version")
assert version, f"Version field is empty in {config_path}"
assert semver_pattern.match(
version
), f"Invalid version format '{version}' in {config_path}"
@pytest.mark.unit
@pytest.mark.parametrize("hook_name", _get_hook_names())
def test_valid_stages(self, hook_configs, hook_name):
"""Test that stages contain only valid OCI hook stages."""
hook_data = hook_configs[hook_name]
config = hook_data["config"]
config_path = hook_data["path"]
valid_stages = ["prestart", "precreate", "poststart", "poststop"]
stages = config.get("stages", [])
assert isinstance(
stages, list
), f"Stages must be a list in {config_path}"
assert (
len(stages) > 0
), f"At least one stage must be specified in {config_path}"
assert all(
stage in valid_stages for stage in stages
), f"Invalid stages in {config_path}. Valid stages: {valid_stages}"
@pytest.mark.unit
@pytest.mark.parametrize("hook_name", _get_hook_names())
def test_annotation_patterns(self, hook_configs, hook_name):
"""Test that annotation patterns are valid regular expressions."""
hook_data = hook_configs[hook_name]
config = hook_data["config"]
config_path = hook_data["path"]
when_section = config.get("when", {})
annotations = when_section.get("annotations", {})
def is_valid_regex(pattern):
try:
re.compile(pattern)
return True
except re.error:
return False
invalid_patterns = {
p: "key" for p in annotations.keys() if not is_valid_regex(p)
}
invalid_value_patterns = {
v: "value"
for v in annotations.values()
if isinstance(v, str) and not is_valid_regex(v)
}
assert not invalid_patterns, (
f"Invalid regex in annotation keys: {invalid_patterns} "
f"in {config_path}"
)
assert not invalid_value_patterns, (
f"Invalid regex in annotation values: {invalid_value_patterns} "
f"in {config_path}"
)
@pytest.mark.integration
@pytest.mark.parametrize("hook_name", _get_hook_names())
def test_hook_executable_exists(self, hook_configs, hook_name):
"""Test that hook executables exist and are executable."""
hook_data = hook_configs[hook_name]
config_path = hook_data["path"]
hook_path = Path(hook_data["config"]["hook"]["path"])
# For tests, the hook might be in the source directory rather than
# installed location - try absolute path first, then relative
try:
# Check if absolute path works
hook_path.stat()
except OSError:
# If absolute path fails, try relative to config file
hook_path = config_path.parent / hook_path.name
assert hook_path.exists(), f"Hook executable not found: {hook_path}"
assert hook_path.is_file(), f"Hook executable not a file: {hook_path}"
@pytest.mark.unit
def test_device_manager_annotations(self, hook_configs):
"""Test that device manager config matches supported annotations."""
hook_name = "oci-qm-device-manager"
assert hook_name in hook_configs, f"Hook {hook_name} not found"
device_manager_config = hook_configs[hook_name]["config"]
annotations = device_manager_config.get("when", {}).get(
"annotations", {}
)
# Check that device patterns exist
device_patterns = [p for p in annotations.keys() if "device" in p]
assert (
device_patterns
), "Device manager should support device annotations"
# Check that wayland patterns exist
wayland_patterns = [p for p in annotations.keys() if "wayland" in p]
assert (
wayland_patterns
), "Device manager should support Wayland annotations"
# Test that device patterns match expected device types
test_annotations = [
"org.containers.qm.device.audio",
"org.containers.qm.device.video",
"org.containers.qm.device.input",
"org.containers.qm.device.ttys",
]
unmatched_patterns = [
p
for p in device_patterns
if not all(re.match(p, a) for a in test_annotations)
]
assert not unmatched_patterns, (
f"Device patterns {unmatched_patterns} failed to match all "
"expected annotations"
)
@pytest.mark.unit
def test_wayland_client_annotations(self, hook_configs):
"""Test that wayland client config matches supported annotations."""
hook_name = "oci-qm-wayland-client-devices"
assert hook_name in hook_configs, f"Hook {hook_name} not found"
wayland_config = hook_configs[hook_name]["config"]
annotations = wayland_config.get("when", {}).get("annotations", {})
# Check that wayland-client patterns exist
wayland_patterns = [
p for p in annotations.keys() if "wayland-client" in p
]
assert (
wayland_patterns
), "Wayland client should have wayland-client pattern"
# Test that patterns match expected annotations
test_annotations = ["org.containers.qm.wayland-client.gpu"]
unmatched_patterns = [
p
for p in wayland_patterns
if not all(re.match(p, a) for a in test_annotations)
]
assert not unmatched_patterns, (
f"Wayland patterns {unmatched_patterns} failed to match all "
"expected annotations"
)
@pytest.mark.unit
def test_no_duplicate_annotations(self, hook_configs):
"""Test that there are no overlapping annotation patterns."""
# Create a flat list of all annotation patterns from all hooks
all_patterns = [
pattern
for hook_data in hook_configs.values()
for pattern in hook_data["config"]
.get("when", {})
.get("annotations", {})
.keys()
]
# Count occurrences of each pattern
pattern_counts = Counter(all_patterns)
# Assert no duplicate patterns exist using named expression
assert not (
duplicates := {
pattern: count
for pattern, count in pattern_counts.items()
if count > 1
}
), "Duplicate annotation patterns found: {}".format( # pylint: disable=C0209 # noqa: E501
{
pattern: [
name
for name, data in hook_configs.items()
if pattern
in data["config"].get("when", {}).get("annotations", {})
]
for pattern in duplicates
}
)
@pytest.mark.unit
@pytest.mark.parametrize("hook_name", _get_hook_names())
def test_json_formatting(self, hook_configs, hook_name):
"""Test that JSON file is properly formatted."""
hook_data = hook_configs[hook_name]
config_path = hook_data["path"]
config = hook_data["config"]
# Read the original file content
with open(config_path, "r", encoding="utf-8") as f:
original_content = f.read()
# Generate formatted JSON
_ = json.dumps(config, indent=2, sort_keys=False) + "\n"
# Check if formatting is consistent (allowing for minor variations)
# This is a basic check - in practice you might want to be
# more lenient
assert (
len(original_content.strip()) > 0
), f"JSON file {config_path} appears to be empty"
# Check that it's valid JSON by trying to parse it
try:
json.loads(original_content)
except json.JSONDecodeError as e:
pytest.fail(f"Invalid JSON in {config_path}: {e}")
@pytest.mark.integration
@pytest.mark.parametrize("hook_name", _get_hook_names())
def test_hook_config_integration(self, hook_configs, hook_name):
"""Test that hook config integrates correctly with its executable."""
hook_data = hook_configs[hook_name]
config = hook_data["config"]
config_path = hook_data["path"]
# Get hook executable path
hook_path = config.get("hook", {}).get("path", "")
assert hook_path, f"Hook path not found in {config_path}"
# Try absolute path first, then relative to config directory
full_hook_path = Path(hook_path)
try:
# Check if absolute path works
full_hook_path.stat()
except OSError:
# If absolute path fails, try relative to config directory
full_hook_path = config_path.parent / Path(hook_path).name
# Check that executable exists
assert full_hook_path.exists(), (
f"Hook executable not found: {full_hook_path} "
f"(configured in {config_path})"
)
# Check that it's executable
assert (
full_hook_path.is_file()
), f"Hook path is not a file: {full_hook_path}"
# Basic executable check (if we can check permissions)
try:
assert (
full_hook_path.stat().st_mode & 0o111
), f"Hook file is not executable: {full_hook_path}"
except OSError:
# Skip permission check if we can't access file stats
pass
@pytest.mark.unit
@pytest.mark.parametrize("hook_name", _get_hook_names())
def test_stage_appropriateness(self, hook_configs, hook_name):
"""Test that hook uses appropriate stages for its functionality."""
hook_data = hook_configs[hook_name]
config = hook_data["config"]
stages = config.get("stages", [])
# Device management hooks should use precreate stage
# because they need to modify the OCI spec before container creation
assert "precreate" in stages, (
f"Hook {hook_name} should use 'precreate' stage for device "
"management"
)
# Should not use poststop for device hooks
assert (
"poststop" not in stages
), f"Device hook {hook_name} should not use 'poststop' stage"

View File

@ -0,0 +1,107 @@
#!/usr/bin/env python3
"""Basic functionality tests for QM device manager."""
import pytest
from conftest import DeviceCounter
class TestQMOCIHooksBasic:
"""Basic functionality tests for QM device manager."""
@pytest.mark.unit
@pytest.mark.parametrize(
"hook_name", ["qm_device_manager", "wayland_client_devices"]
)
@pytest.mark.parametrize(
"json_spec",
[
{"annotations": {}, "linux": {}},
{
"annotations": {"org.containers.qm.device.unknown": "true"},
"linux": {},
},
{
"annotations": {"org.containers.qm.device.audio": "false"},
"linux": {},
},
],
ids=["empty_spec", "unknown_spec", "invalid_spec"],
)
def test_basic_annotations(
self, hook_name, json_spec, hook_runner, oci_spec_validator
):
"""Test hook with basic annotations returns unchanged spec."""
result = hook_runner.run_hook(hook_name, json_spec)
assert result.success, f"Hook failed: {result.stderr}"
assert oci_spec_validator(result.output_spec), "Output spec is invalid"
assert (
DeviceCounter.count_devices(result.output_spec) == 0
), "No devices should be added"
@pytest.mark.unit
@pytest.mark.parametrize(
"invalid_spec",
[
# Missing required 'linux' property
{"annotations": {"com.example": "value"}},
# 'annotations' should be an object, not a string
{"annotations": "not-an-object", "linux": {}},
# Add a property not defined in the schema
{
"annotations": {"com.example": "value"},
"linux": {},
"extra": 123,
},
],
)
def test_invalid_annotations(self, oci_spec_validator, invalid_spec):
"""Test invalid annotations are rejected."""
assert not oci_spec_validator(
invalid_spec
), "Invalid spec should be rejected"
@pytest.mark.unit
def test_missing_linux_section(self, hook_runner):
"""Test hook creates linux section if missing."""
malformed_json = {
"annotations": {"org.containers.qm.device.audio": "true"}
# Missing linux section
}
result = hook_runner.run_hook("qm_device_manager", malformed_json)
assert result.success, f"Hook failed: {result.stderr}"
assert (
"linux" in result.output_spec
), "Hook should create linux section"
assert isinstance(
result.output_spec["linux"], dict
), "Linux section should be a dict"
@pytest.mark.unit
@pytest.mark.parametrize(
"json_spec",
[
# Non-wayland annotations ignored
{
"annotations": {"org.containers.qm.device.audio": "true"},
"linux": {},
},
# Unknown wayland annotations ignored
{
"annotations": {
"org.containers.qm.wayland-client.unknown": "true"
},
"linux": {},
},
],
ids=["non_wayland_ignored", "unknown_wayland_ignored"],
)
def test_wayland_annotations(self, hook_runner, json_spec, device_counter):
"""Test wayland annotations are processed correctly."""
result = hook_runner.run_hook("wayland_client_devices", json_spec)
assert result.success, f"Hook failed: {result.stderr}"
assert (
device_counter.count_devices(result.output_spec) == 0
), "No devices should be added"

View File

@ -0,0 +1,155 @@
#!/usr/bin/env python3
"""Device detection and annotation tests for QM device manager."""
import pytest
from conftest import DeviceCounter
class TestQMDeviceManagerDevices:
"""Device annotation tests for QM device manager."""
@pytest.mark.integration
@pytest.mark.hook_execution
def test_audio_device_annotation(
self, hook_runner, sample_specs, device_counter
):
"""Test audio device annotation executes successfully."""
result = hook_runner.run_hook(
"qm_device_manager", sample_specs["audio_device"]
)
assert result.success, f"Hook failed: {result.stderr}"
# Hook should succeed and produce valid output regardless of
# available devices (device-specific testing in temp_devices tests)
total_devices = device_counter.count_devices(result.output_spec)
assert total_devices >= 0, "Device count should be non-negative"
@pytest.mark.integration
@pytest.mark.hook_execution
def test_multiple_device_annotations(
self, hook_runner, sample_specs, device_counter
):
"""Test multiple device annotations."""
result = hook_runner.run_hook(
"qm_device_manager", sample_specs["multiple_devices"]
)
assert result.success, f"Hook failed: {result.stderr}"
# Should have devices for at least one of the requested types
# (depending on what's available in test environment)
total_devices = device_counter.count_devices(result.output_spec)
# Even if no devices are available, hook should succeed
assert total_devices >= 0, "Device count should be non-negative"
@pytest.mark.unit
@pytest.mark.parametrize(
"device_type,annotation_key",
[
("audio", "org.containers.qm.device.audio"),
("video", "org.containers.qm.device.video"),
("input", "org.containers.qm.device.input"),
("ttys", "org.containers.qm.device.ttys"),
("ttyUSB", "org.containers.qm.device.ttyUSB"),
("dvb", "org.containers.qm.device.dvb"),
("radio", "org.containers.qm.device.radio"),
],
)
def test_individual_device_types(
self, hook_runner, device_type, annotation_key
):
"""Test individual device type annotations."""
spec = {"annotations": {annotation_key: "true"}, "linux": {}}
result = hook_runner.run_hook("qm_device_manager", spec)
assert (
result.success
), f"Hook failed for {device_type}: {result.stderr}"
# Validate result.output_spec structure
assert "linux" in result.output_spec
assert ("devices" in result.output_spec.get("linux", {})) or (
DeviceCounter.count_devices(result.output_spec) == 0
)
@pytest.mark.unit
def test_audio_device_annotation_with_temp_devices(
self, hook_runner, sample_specs, device_counter, temp_devices
):
"""Test audio device annotation with specific device creation."""
device_detector = temp_devices.get_audio_devices()
result = hook_runner.run_hook(
"qm_device_manager",
sample_specs["audio_device"],
mock_devices=device_detector.mock_devices(device_type="audio"),
)
assert result.success, f"Hook failed: {result.stderr}"
# Verify expected number of devices
device_count = device_counter.count_devices(result.output_spec)
assert device_count == device_detector.expected_count, (
f"Expected {device_detector.expected_count} audio devices, "
f"got {device_count}"
)
# Verify the specific temporary devices are present
devices = result.output_spec.get("linux", {}).get("devices", [])
found_paths = [d["path"] for d in devices]
assert all(
temp_path in found_paths for temp_path in device_detector.paths
), (
f"All temporary audio devices should be found in output. "
f"Expected: {device_detector.paths}, Found: {found_paths}"
)
@pytest.mark.unit
@pytest.mark.parametrize(
"device_type,device_method",
[
("video", "get_video_devices"),
("input", "get_input_devices"),
("dvb", "get_dvb_devices"),
("ttyUSB", "get_ttyusb_devices"),
("radio", "get_radio_devices"),
("ttys", "get_ttys_devices"),
],
)
# pylint: disable=too-many-positional-arguments
def test_device_annotation_with_temp_devices(
self,
hook_runner,
device_counter,
temp_devices,
device_type,
device_method,
):
"""Test various device types with specific device creation."""
device_detector = getattr(temp_devices, device_method)()
annotation_key = f"org.containers.qm.device.{device_type}"
spec = {
"annotations": {annotation_key: "true"},
"linux": {},
}
result = hook_runner.run_hook(
"qm_device_manager",
spec,
mock_devices=device_detector.mock_devices(device_type),
)
assert (
result.success
), f"Hook failed for {device_type}: {result.stderr}"
device_count = device_counter.count_devices(result.output_spec)
assert device_count == device_detector.expected_count, (
f"Expected {device_detector.expected_count} {device_type} "
f"devices, got {device_count}"
)

View File

@ -0,0 +1,134 @@
#!/usr/bin/env python3
"""Error handling and edge case tests for QM device manager."""
import pytest
class TestQMDeviceManagerErrorHandling:
"""Error handling and edge case tests."""
@pytest.mark.unit
@pytest.mark.hook_execution
def test_non_qm_annotations_ignored(self, hook_runner, device_counter):
"""Test that non-QM annotations are ignored."""
spec = {
"annotations": {
"org.opencontainers.image.title": "test",
"com.example.custom": "value",
},
"linux": {},
}
result = hook_runner.run_hook("qm_device_manager", spec)
assert result.success, f"Hook failed: {result.stderr}"
assert (
device_counter.count_devices(result.output_spec) == 0
), "No devices should be added for non-QM annotations"
@pytest.mark.unit
def test_hook_with_existing_devices(self, hook_runner):
"""Test hook behavior when devices already exist."""
spec_with_devices = {
"annotations": {"org.containers.qm.device.audio": "true"},
"linux": {
"devices": [
{
"path": "/dev/existing",
"type": "c",
"major": 1,
"minor": 3,
}
]
},
}
result = hook_runner.run_hook("qm_device_manager", spec_with_devices)
assert result.success, f"Hook failed: {result.stderr}"
# Should preserve existing devices and potentially add new ones
devices = result.output_spec.get("linux", {}).get("devices", [])
existing_paths = [d["path"] for d in devices]
assert (
"/dev/existing" in existing_paths
), "Existing device should be preserved"
@pytest.mark.unit
def test_hook_with_existing_resources(self, hook_runner):
"""Test hook behavior when resources already exist."""
spec_with_resources = {
"annotations": {"org.containers.qm.wayland.seat": "true"},
"linux": {
"resources": {"devices": [{"allow": False, "type": "a"}]}
},
}
result = hook_runner.run_hook("qm_device_manager", spec_with_resources)
assert result.success, f"Hook failed: {result.stderr}"
# Should preserve existing resources
resources = result.output_spec.get("linux", {}).get("resources", {})
devices_limit = resources.get("devices", [])
# Check that the existing rule is preserved
deny_all_rule = {"allow": False, "type": "a"}
assert (
deny_all_rule in devices_limit
), "Existing resource rules should be preserved"
class TestWaylandClientDevicesErrorHandling:
"""Error handling tests for Wayland client devices."""
@pytest.mark.unit
def test_annotation_isolation(self, hook_runner, device_counter):
"""Test that invalid annotations don't affect valid ones."""
spec = {
"annotations": {
"org.containers.qm.wayland-client.gpu": "true",
"org.containers.qm.wayland-client.invalid": "bad_value",
"org.opencontainers.image.title": "test",
},
"linux": {},
}
result = hook_runner.run_hook("wayland_client_devices", spec)
assert result.success, f"Hook failed: {result.stderr}"
# Valid GPU annotation should still be processed
total_devices = device_counter.count_devices(result.output_spec)
assert total_devices >= 0, "Valid annotations should be processed"
@pytest.mark.unit
def test_empty_annotation_value(self, hook_runner, device_counter):
"""Test handling of empty annotation values."""
spec = {
"annotations": {"org.containers.qm.wayland-client.gpu": ""},
"linux": {},
}
result = hook_runner.run_hook("wayland_client_devices", spec)
assert result.success, f"Hook failed: {result.stderr}"
# Empty value should be treated as falsy
total_devices = device_counter.count_devices(result.output_spec)
assert (
total_devices == 0
), "Empty annotation value should not add devices"
@pytest.mark.unit
def test_unknown_annotation_values(self, hook_runner, device_counter):
"""Test handling of unknown annotation values."""
spec = {
"annotations": {"org.containers.qm.wayland-client.gpu": "maybe"},
"linux": {},
}
result = hook_runner.run_hook("wayland_client_devices", spec)
assert result.success, f"Hook failed: {result.stderr}"
# Unknown values should typically be treated as falsy for safety
total_devices = device_counter.count_devices(result.output_spec)
assert (
total_devices == 0
), "Unknown annotation values should not add devices"

View File

@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""Logging tests for QM device manager and Wayland client devices."""
import pytest
class TestQMDeviceManagerLogging:
"""Logging functionality tests."""
@pytest.mark.unit
@pytest.mark.hook_execution
def test_log_file_creation(self, hook_runner, log_checker):
"""Test that hook creates log file."""
empty_spec = {"annotations": {}, "linux": {}}
result = hook_runner.run_hook("qm_device_manager", empty_spec)
assert result.success, f"Hook failed: {result.stderr}"
assert log_checker("qm-device-manager"), "Log should contain hook name"
@pytest.mark.unit
@pytest.mark.hook_execution
def test_log_content_validation(
self, hook_runner, sample_specs, log_checker
):
"""Test that hook logs contain expected content."""
result = hook_runner.run_hook(
"qm_device_manager", sample_specs["audio_device"]
)
assert result.success, f"Hook failed: {result.stderr}"
assert log_checker(
"QM Device Manager"
), "Log should contain hook description"
assert log_checker("completed successfully")
class TestWaylandClientDevicesLogging:
"""Logging functionality tests."""
@pytest.mark.unit
@pytest.mark.hook_execution
def test_log_file_creation(self, hook_runner, log_checker):
"""Test that hook creates log file."""
empty_spec = {"annotations": {}, "linux": {}}
result = hook_runner.run_hook("wayland_client_devices", empty_spec)
assert result.success, f"Hook failed: {result.stderr}"
assert log_checker(
"qm-wayland-client-devices"
), "Log should contain hook name"
@pytest.mark.unit
@pytest.mark.hook_execution
def test_log_content_validation(
self, hook_runner, sample_specs, log_checker
):
"""Test that hook logs contain expected content."""
result = hook_runner.run_hook(
"wayland_client_devices", sample_specs["wayland_gpu"]
)
assert result.success, f"Hook failed: {result.stderr}"
assert log_checker(
"qm-wayland-client-devices"
), "Log should contain hook name"

View File

@ -0,0 +1,89 @@
#!/usr/bin/env python3
"""Performance tests for QM device manager and Wayland client devices."""
import time
import pytest
class TestQMDeviceManagerPerformance:
"""Performance and timing tests."""
@pytest.mark.performance
def test_hook_performance(self, hook_runner, sample_specs):
"""Test that hook executes within reasonable time."""
start_time = time.time()
result = hook_runner.run_hook(
"qm_device_manager", sample_specs["multiple_devices"]
)
end_time = time.time()
assert result.success, f"Hook failed: {result.stderr}"
execution_time = end_time - start_time
# Hook should complete within 5 seconds (generous limit)
assert (
execution_time < 5.0
), f"Hook took too long: {execution_time:.2f}s"
@pytest.mark.performance
@pytest.mark.parametrize("iteration", range(5))
def test_multiple_executions_performance(
self, hook_runner, sample_specs, iteration
):
"""Test consistent performance across multiple executions."""
start_time = time.time()
result = hook_runner.run_hook(
"qm_device_manager", sample_specs["audio_device"]
)
end_time = time.time()
assert (
result.success
), f"Hook failed on iteration {iteration}: {result.stderr}"
execution_time = end_time - start_time
assert (
execution_time < 3.0
), f"Execution {iteration} too slow: {execution_time:.2f}s"
class TestWaylandClientDevicesPerformance:
"""Performance tests for Wayland client devices."""
@pytest.mark.performance
def test_hook_performance(self, hook_runner, sample_specs):
"""Test Wayland client devices hook performance."""
start_time = time.time()
result = hook_runner.run_hook(
"wayland_client_devices", sample_specs["wayland_gpu"]
)
end_time = time.time()
assert result.success, f"Hook failed: {result.stderr}"
execution_time = end_time - start_time
assert (
execution_time < 3.0
), f"Hook took too long: {execution_time:.2f}s"
@pytest.mark.performance
@pytest.mark.parametrize("iteration", range(3))
def test_multiple_executions_performance(
self, hook_runner, sample_specs, iteration
):
"""Test consistent performance across multiple executions."""
start_time = time.time()
result = hook_runner.run_hook(
"wayland_client_devices", sample_specs["wayland_gpu"]
)
end_time = time.time()
assert (
result.success
), f"Hook failed on iteration {iteration}: {result.stderr}"
execution_time = end_time - start_time
assert (
execution_time < 2.0
), f"Execution {iteration} too slow: {execution_time:.2f}s"

View File

@ -0,0 +1,129 @@
#!/usr/bin/env python3
"""Validation tests for QM device manager."""
import pytest
class TestQMDeviceManagerValidation:
"""JSON and structure validation tests."""
@pytest.mark.unit
def test_output_json_structure(
self, hook_runner, sample_specs, oci_spec_validator
):
"""Test that hook output follows OCI specification structure."""
result = hook_runner.run_hook(
"qm_device_manager", sample_specs["audio_device"]
)
assert result.success, f"Hook failed: {result.stderr}"
assert oci_spec_validator(result.output_spec), "Output spec is invalid"
@pytest.mark.unit
def test_device_structure_validation(self, hook_runner, sample_specs):
"""Test that device entries have correct structure."""
result = hook_runner.run_hook(
"qm_device_manager", sample_specs["multiple_devices"]
)
assert result.success, f"Hook failed: {result.stderr}"
devices = result.output_spec.get("linux", {}).get("devices", [])
# Check that all devices have required fields
required_fields = ["path", "type", "major", "minor"]
assert all(
field in device for device in devices for field in required_fields
), "All devices must have required fields: path, type, major, minor"
# Check path types
assert all(
isinstance(d["path"], str) for d in devices
), "Path must be str"
# Check type values
assert all(
d["type"] in ["c", "b"] for d in devices
), "Type must be c or b"
# Check major/minor are integers
assert all(
isinstance(d["major"], int) for d in devices
), "Major must be int"
assert all(
isinstance(d["minor"], int) for d in devices
), "Minor must be int"
@pytest.mark.unit
def test_resource_structure_validation(self, hook_runner):
"""Test that resource entries have correct structure."""
spec = {
"annotations": {"org.containers.qm.wayland.seat": "true"},
"linux": {},
}
result = hook_runner.run_hook("qm_device_manager", spec)
assert result.success, f"Hook failed: {result.stderr}"
# Check devices limit structure
devices_limit = (
result.output_spec.get("linux", {})
.get("resources", {})
.get("devices", [])
)
# Validate all device rules have required fields
assert all(
"allow" in device_rule for device_rule in devices_limit
), "All device rules must have 'allow' field"
assert all(
"type" in device_rule for device_rule in devices_limit
), "All device rules must have 'type' field"
assert all(
isinstance(device_rule["allow"], bool)
for device_rule in devices_limit
), "All 'allow' fields must be bool"
assert all(
device_rule["type"] in ["c", "b", "a"]
for device_rule in devices_limit
), "All device types must be 'c', 'b', or 'a'"
@pytest.mark.unit
def test_empty_annotations_validation(
self, hook_runner, oci_spec_validator
):
"""Test validation with empty annotations."""
empty_spec = {"annotations": {}, "linux": {}}
result = hook_runner.run_hook("qm_device_manager", empty_spec)
assert result.success, f"Hook failed: {result.stderr}"
assert oci_spec_validator(result.output_spec), "Output should be valid"
@pytest.mark.unit
@pytest.mark.parametrize(
"malformed_spec",
[
# Non-string annotation values should be handled gracefully
{
"annotations": {"org.containers.qm.device.audio": True},
"linux": {},
},
{
"annotations": {"org.containers.qm.device.audio": 123},
"linux": {},
},
{
"annotations": {"org.containers.qm.device.audio": []},
"linux": {},
},
],
ids=["boolean_value", "integer_value", "list_value"],
)
def test_malformed_annotation_values(self, hook_runner, malformed_spec):
"""Test handling of malformed annotation values."""
result = hook_runner.run_hook("qm_device_manager", malformed_spec)
# Hook should not crash, but may not process the annotation
assert (
result.success
), f"Hook should handle malformed values: {result.stderr}"

View File

@ -0,0 +1,97 @@
#!/usr/bin/env python3
"""Wayland seat functionality tests for QM device manager."""
import pytest
class TestQMDeviceManagerWayland:
"""Wayland seat annotation tests."""
@pytest.mark.unit
@pytest.mark.parametrize(
"seat_annotation",
[
"org.containers.qm.wayland.seat",
"org.containers.qm.wayland.seat.0",
"org.containers.qm.wayland.seat.1",
],
)
def test_wayland_seat_annotation(
self, hook_runner, seat_annotation, device_counter
):
"""Test Wayland seat annotations."""
spec = {
"annotations": {seat_annotation: "true"},
"linux": {},
}
result = hook_runner.run_hook("qm_device_manager", spec)
assert result.success, f"Hook failed: {result.stderr}"
# Verify devices and resources are added for seat
device_count = device_counter.count_devices(result.output_spec)
resource_count = device_counter.count_resources(result.output_spec)
assert device_count >= 0, "Device count should be non-negative"
assert resource_count >= 0, "Resource count should be non-negative"
@pytest.mark.unit
def test_wayland_seat_with_existing_devices(self, hook_runner):
"""Test wayland seat annotation with existing devices."""
spec_with_devices = {
"annotations": {"org.containers.qm.wayland.seat": "true"},
"linux": {
"devices": [
{
"path": "/dev/existing",
"type": "c",
"major": 1,
"minor": 3,
}
]
},
}
result = hook_runner.run_hook("qm_device_manager", spec_with_devices)
assert result.success, f"Hook failed: {result.stderr}"
# Should preserve existing devices
devices = result.output_spec.get("linux", {}).get("devices", [])
existing_paths = [d["path"] for d in devices]
assert (
"/dev/existing" in existing_paths
), "Existing device should be preserved"
@pytest.mark.unit
@pytest.mark.parametrize(
"spec",
[
{
"annotations": {
"org.containers.qm.wayland.seat.0": "true",
"org.containers.qm.wayland.seat.1": "true",
},
"linux": {},
},
{
"annotations": {
"org.containers.qm.wayland.seat": "seat0",
"org.containers.qm.wayland.seat.0": "true",
"org.containers.qm.wayland.seat.1": "true",
},
"linux": {},
},
],
ids=["multiple_seats", "conflicting_seats"],
)
def test_multiple_wayland_seats(self, hook_runner, device_counter, spec):
"""Test multiple Wayland seat annotations."""
result = hook_runner.run_hook("qm_device_manager", spec)
assert result.success, f"Hook failed: {result.stderr}"
# Should process both seats
device_count = device_counter.count_devices(result.output_spec)
resource_count = device_counter.count_resources(result.output_spec)
assert device_count >= 0, "Should handle multiple seats"
assert resource_count >= 0, "Resource count should be non-negative"

View File

@ -0,0 +1,238 @@
"""Shared test utilities for OCI hooks testing."""
import json
import subprocess
import time
from pathlib import Path
from dataclasses import dataclass
from typing import Dict, Any, Optional
import jsonschema
@dataclass
class HookResult:
"""Result of running an OCI hook."""
success: bool
output_spec: Dict[str, Any]
stdout: str
stderr: str
execution_time: float
class HookRunner:
"""Class for running OCI hooks in test environment."""
def __init__(self, test_env: Dict[str, str]):
"""Initialize with test environment variables."""
self.test_env = test_env
def run_hook(
self,
hook_name: str,
input_spec: Dict[str, Any],
mock_devices: Optional[Dict[str, str]] = None,
) -> HookResult:
"""Run a hook with given input spec and return result."""
hook_paths = {
"qm_device_manager": "../qm-device-manager/oci-qm-device-manager",
"wayland_client_devices": (
"../wayland-client-devices/oci-qm-wayland-client-devices"
),
}
if hook_name not in hook_paths:
raise ValueError(f"Unknown hook: {hook_name}")
hook_path = hook_paths[hook_name]
input_json = json.dumps(input_spec)
# Use provided test environment (including TEST_LOGFILE)
env = self.test_env.copy()
# Add mock device environment variables if provided
if mock_devices:
for device_type, device_list in mock_devices.items():
env_var = f"TEST_MOCK_{device_type.upper()}_DEVICES"
env[env_var] = device_list
start_time = time.time()
result = subprocess.run(
[hook_path],
input=input_json,
text=True,
capture_output=True,
env=env,
cwd=Path(__file__).parent,
check=False,
)
execution_time = time.time() - start_time
# Parse output JSON if hook succeeded
output_spec = {}
if result.returncode == 0 and result.stdout.strip():
try:
output_spec = json.loads(result.stdout)
except json.JSONDecodeError:
# If output is not valid JSON, treat as failure
pass
return HookResult(
success=result.returncode == 0,
output_spec=output_spec,
stdout=result.stdout,
stderr=result.stderr,
execution_time=execution_time,
)
class HookConfigLoader:
"""Utility for loading and managing hook configurations."""
@staticmethod
def load_all_hook_configs() -> Dict[str, Dict[str, Any]]:
"""Load all hook configuration files."""
hook_dir = Path(__file__).parent.parent
def _load_config(json_file):
with open(json_file, "r", encoding="utf-8") as f:
return json.load(f)
return {
json_file.stem: {
"path": json_file,
"config": _load_config(json_file),
}
for json_file in hook_dir.rglob("*.json")
if "oci-qm-" in json_file.name
}
@staticmethod
def get_hook_names() -> list:
"""Get list of all hook names."""
return list(HookConfigLoader.load_all_hook_configs().keys())
class OciSpecValidator:
"""JSON schema validator for OCI specifications."""
# Simplified OCI spec schema for validation
OCI_SCHEMA = {
"type": "object",
"properties": {
"annotations": {
"type": "object",
"additionalProperties": {"type": "string"},
},
"linux": {
"type": "object",
"properties": {
"devices": {
"type": "array",
"items": {
"type": "object",
"properties": {
"path": {"type": "string"},
"type": {"type": "string"},
"major": {"type": "integer"},
"minor": {"type": "integer"},
"fileMode": {"type": "integer"},
"uid": {"type": "integer"},
"gid": {"type": "integer"},
},
"required": ["path", "type", "major", "minor"],
},
},
"resources": {
"type": "object",
"properties": {
"devices": {
"type": "array",
"items": {
"type": "object",
"properties": {
"allow": {"type": "boolean"},
"type": {"type": "string"},
"major": {"type": "integer"},
"minor": {"type": "integer"},
"access": {"type": "string"},
},
"required": [
"allow",
"type",
"major",
"minor",
"access",
],
},
}
},
},
},
"additionalProperties": False,
},
},
"required": ["annotations", "linux"],
"additionalProperties": False,
}
@classmethod
def validate(cls, spec: Dict[str, Any]) -> bool:
"""Validate an OCI spec against the schema."""
try:
jsonschema.validate(spec, cls.OCI_SCHEMA)
return True
except jsonschema.ValidationError:
return False
class LogChecker:
"""Utility for checking hook log files."""
def __init__(self, log_file_path: str):
"""Initialize with log file path."""
self.log_file_path = Path(log_file_path)
def __call__(self, pattern: str, should_exist: bool = True) -> bool:
"""
Check if pattern exists in hook log file (backward compatibility).
Args:
pattern: String pattern to search for
should_exist: Whether pattern should exist (True) or not (False)
Returns:
True if check passes, False otherwise
"""
if not self.log_exists():
return not should_exist
try:
content = self.log_file_path.read_text(encoding="utf-8")
pattern_found = pattern in content
return pattern_found if should_exist else not pattern_found
except OSError:
return False
def log_exists(self) -> bool:
"""Check if log file exists."""
return self.log_file_path.exists()
def log_contains(self, text: str) -> bool:
"""Check if log contains specific text."""
if not self.log_exists():
return False
content = self.log_file_path.read_text(encoding="utf-8")
return text in content
def get_log_lines(self) -> list:
"""Get all log lines as a list."""
try:
return (
self.log_file_path.read_text(encoding="utf-8")
.strip()
.split("\n")
)
except OSError:
return []

View File

@ -0,0 +1,178 @@
#!/usr/bin/env python3
"""Wayland GPU device tests."""
import pytest
class TestWaylandClientDevicesGPU:
"""GPU device detection tests for Wayland client containers."""
@pytest.mark.unit
def test_gpu_annotation_enabled(self, hook_runner, sample_specs):
"""Test GPU annotation processes correctly."""
result = hook_runner.run_hook(
"wayland_client_devices", sample_specs["wayland_gpu"]
)
assert result.success, f"Hook failed: {result.stderr}"
@pytest.mark.unit
@pytest.mark.parametrize("gpu_value", ["true", "1", "yes", "True", "YES"])
def test_gpu_annotation_valid_values(
self, hook_runner, device_counter, gpu_value
):
"""Test GPU annotation with various valid truthy values."""
spec = {
"annotations": {"org.containers.qm.wayland-client.gpu": gpu_value},
"linux": {},
}
result = hook_runner.run_hook("wayland_client_devices", spec)
assert (
result.success
), f"Hook failed with value '{gpu_value}': {result.stderr}"
# For truthy values, devices should be processed
total_devices = device_counter.count_devices(result.output_spec)
assert (
total_devices >= 0
), f"Truthy value '{gpu_value}' should enable GPU processing"
@pytest.mark.unit
@pytest.mark.parametrize("gpu_value", ["false", "0", "no", "False", "NO"])
def test_gpu_annotation_invalid_values(
self, hook_runner, device_counter, gpu_value
):
"""Test GPU annotation with various falsy values."""
spec = {
"annotations": {"org.containers.qm.wayland-client.gpu": gpu_value},
"linux": {},
}
result = hook_runner.run_hook("wayland_client_devices", spec)
assert (
result.success
), f"Hook failed with falsy value '{gpu_value}': {result.stderr}"
# For falsy values, no devices should be added
total_devices = device_counter.count_devices(result.output_spec)
assert (
total_devices == 0
), f"Falsy value '{gpu_value}' should not add GPU devices"
@pytest.mark.unit
@pytest.mark.parametrize(
"gpu_value", ["TrUe", "YeS", "tRuE", "yEs", "TRUE"]
)
def test_gpu_annotation_case_insensitive_truthy_values(
self, hook_runner, device_counter, gpu_value
):
"""Test GPU annotation with mixed-case truthy values."""
spec = {
"annotations": {"org.containers.qm.wayland-client.gpu": gpu_value},
"linux": {},
}
result = hook_runner.run_hook("wayland_client_devices", spec)
assert (
result.success
), f"Hook failed with mixed-case value '{gpu_value}': {result.stderr}"
total_devices = device_counter.count_devices(result.output_spec)
assert total_devices >= 0, (
f"Mixed-case truthy value '{gpu_value}' should enable GPU "
"processing"
)
@pytest.mark.unit
@pytest.mark.parametrize(
"gpu_value", ["FaLsE", "nO", "fAlSe", "No", "FALSE"]
)
def test_gpu_annotation_case_insensitive_falsy_values(
self, hook_runner, device_counter, gpu_value
):
"""Test GPU annotation with mixed-case falsy values."""
spec = {
"annotations": {"org.containers.qm.wayland-client.gpu": gpu_value},
"linux": {},
}
result = hook_runner.run_hook("wayland_client_devices", spec)
assert (
result.success
), f"Hook failed with mixed-case value '{gpu_value}': {result.stderr}"
# For falsy values, no devices should be added
total_devices = device_counter.count_devices(result.output_spec)
assert (
total_devices == 0
), f"Mixed-case falsy value '{gpu_value}' should not add GPU devices"
@pytest.mark.unit
@pytest.mark.parametrize(
"gpu_value",
[
"2", # Number other than 1
"42", # Random number
"maybe", # Unrelated string
"truthy", # Similar but not exact
"YES_PLEASE", # Contains valid word but extra chars
"null", # Null-like value
"", # Empty string
"[]", # JSON array string
"{}", # JSON object string
"true false", # Multiple values
"TRUE;true", # Semicolon separated
"on", # Common boolean-like value
"off", # Common boolean-like value
"enable", # Enable-like value
"disable", # Disable-like value
],
)
def test_gpu_annotation_malformed_values(
self, hook_runner, device_counter, gpu_value
):
"""Test GPU annotation with malformed or unexpected values."""
spec = {
"annotations": {"org.containers.qm.wayland-client.gpu": gpu_value},
"linux": {},
}
result = hook_runner.run_hook("wayland_client_devices", spec)
# Hook should handle malformed values gracefully without failing
assert result.success, (
f"Hook should not fail with malformed value '{gpu_value}': "
f"{result.stderr}"
)
# Malformed values should be treated as falsy (no GPU devices added)
total_devices = device_counter.count_devices(result.output_spec)
assert (
total_devices == 0
), f"Malformed value '{gpu_value}' should not enable GPU processing"
class TestWaylandClientDevicesGPUDevices:
"""GPU device tests for Wayland client containers."""
@pytest.mark.unit
def test_gpu_annotation(
self, hook_runner, sample_specs, device_counter, temp_devices
):
"""Test GPU annotation with temp devices."""
device_detector = temp_devices.get_gpu_devices()
result = hook_runner.run_hook(
"wayland_client_devices",
sample_specs["wayland_gpu"],
mock_devices=device_detector.mock_devices("gpu"),
)
assert result.success, f"Hook failed: {result.stderr}"
device_count = device_counter.count_devices(result.output_spec)
assert device_count == device_detector.expected_count, (
f"Expected {device_detector.expected_count} GPU devices, "
f"got {device_count}"
)
devices = result.output_spec.get("linux", {}).get("devices", [])
found_paths = [d["path"] for d in devices]
assert all(
temp_path in found_paths for temp_path in device_detector.paths
), "Temporary GPU device not found in output"

82
oci-hooks/tox.ini Normal file
View File

@ -0,0 +1,82 @@
[tox]
envlist = unit
skipsdist = true
[testenv:unit]
deps = -r tests/requirements.txt
allowlist_externals = chmod
passenv = FORCE_MOCK_DEVICES
changedir = tests
commands_pre =
chmod +x {toxinidir}/qm-device-manager/oci-qm-device-manager
chmod +x {toxinidir}/wayland-client-devices/oci-qm-wayland-client-devices
commands =
pytest -m unit --tb=short -v {posargs}
[testenv:integration]
deps = -r tests/requirements.txt
allowlist_externals = chmod
passenv = FORCE_MOCK_DEVICES
changedir = tests
commands_pre =
chmod +x {toxinidir}/qm-device-manager/oci-qm-device-manager
chmod +x {toxinidir}/wayland-client-devices/oci-qm-wayland-client-devices
commands =
pytest -m integration --tb=short -v {posargs}
[testenv:performance]
deps = -r tests/requirements.txt
allowlist_externals = chmod
passenv = FORCE_MOCK_DEVICES
changedir = tests
commands_pre =
chmod +x {toxinidir}/qm-device-manager/oci-qm-device-manager
chmod +x {toxinidir}/wayland-client-devices/oci-qm-wayland-client-devices
commands =
pytest -m performance --tb=short -v {posargs}
[testenv:all]
deps = -r tests/requirements.txt
allowlist_externals = chmod
passenv = FORCE_MOCK_DEVICES
changedir = tests
commands_pre =
chmod +x {toxinidir}/qm-device-manager/oci-qm-device-manager
chmod +x {toxinidir}/wayland-client-devices/oci-qm-wayland-client-devices
commands =
pytest --tb=short -v {posargs}
[testenv:lint]
deps =
black
pylint
shellcheck-py
allowlist_externals = shfmt
commands =
black --check --diff --line-length 79 tests/
pylint --rcfile=tests/.pylintrc tests/*.py
shellcheck qm-device-manager/oci-qm-device-manager
shellcheck wayland-client-devices/oci-qm-wayland-client-devices
shellcheck lib/common.sh
shellcheck lib/device-support.sh
shellcheck lib/mock-device-support.sh
shfmt -d -i 4 qm-device-manager/oci-qm-device-manager
shfmt -d -i 4 wayland-client-devices/oci-qm-wayland-client-devices
shfmt -d -i 4 lib/common.sh
shfmt -d -i 4 lib/device-support.sh
shfmt -d -i 4 lib/mock-device-support.sh
[testenv:format]
deps = black
allowlist_externals = shfmt
commands =
black --line-length 79 tests/
shfmt -w -i 4 qm-device-manager/oci-qm-device-manager
shfmt -w -i 4 wayland-client-devices/oci-qm-wayland-client-devices
shfmt -w -i 4 lib/common.sh
shfmt -w -i 4 lib/device-support.sh
shfmt -w -i 4 lib/mock-device-support.sh
[flake8]
max-line-length = 79
extend-ignore = E203, W503

View File

@ -2,19 +2,26 @@
set -euo pipefail
# Configuration
LOGFILE="/var/log/qm-wayland-client-devices.log"
HOOK_NAME="qm-wayland-client-devices"
# Source common utilities and appropriate device support library
SCRIPT_DIR="$(dirname "${BASH_SOURCE[0]}")"
# Logging function
log() {
local level="$1"
shift
echo "$(date '+%Y-%m-%d %H:%M:%S') - $HOOK_NAME - $level - $*" >> "$LOGFILE"
if [[ "$level" == "ERROR" ]]; then
echo "$(date '+%Y-%m-%d %H:%M:%S') - $HOOK_NAME - $level - $*" >&2
fi
}
# shellcheck source=../lib/common.sh disable=SC1091
source "${SCRIPT_DIR}/../lib/common.sh"
if [[ -n "${TEST_LOGFILE:-}" ]]; then
# Test mode - use mock device support
# shellcheck source=../lib/mock-device-support.sh disable=SC1091
source "${SCRIPT_DIR}/../lib/mock-device-support.sh"
else
# Normal mode - use standard device support
# shellcheck source=../lib/device-support.sh disable=SC1091
source "${SCRIPT_DIR}/../lib/device-support.sh"
fi
# Configuration
LOGFILE="${TEST_LOGFILE:-/var/log/qm-wayland-client-devices.log}"
# shellcheck disable=SC2034 # Used by log() function in device-support.sh
HOOK_NAME="qm-wayland-client-devices"
input="-"
CONTAINER_CONFIG=$(cat "$input")
@ -28,11 +35,11 @@ GPU_ENABLED=$(echo "$CONTAINER_CONFIG" | jq -r '.annotations["org.containers.qm.
DEVNAME_LIST=()
if [ -n "$GPU_ENABLED" ]; then
log "INFO" "Processing Wayland client GPU annotation: $GPU_ENABLED"
if [[ "$GPU_ENABLED" =~ ^(true|1|yes|True|TRUE|YES)$ ]]; then
log "INFO" "Processing Wayland client GPU annotation: $GPU_ENABLED (enabled)"
# Find all the render devices available
RENDER_DEVICES=$(find /dev/dri -type c \( -regex ".*/render.*" \))
RENDER_DEVICES=$(discover_gpu_devices)
log "INFO" "Scanning for GPU render devices"
for RENDER_DEVICE in $RENDER_DEVICES; do
@ -41,6 +48,8 @@ if [ -n "$GPU_ENABLED" ]; then
done
log "INFO" "Found ${#DEVNAME_LIST[@]} GPU render devices"
elif [ -n "$GPU_ENABLED" ]; then
log "INFO" "Wayland client GPU annotation present but disabled: $GPU_ENABLED"
else
log "INFO" "No Wayland client GPU annotation found"
fi
@ -50,16 +59,24 @@ if [ ${#DEVNAME_LIST[@]} -gt 0 ]; then
log "INFO" "Processing ${#DEVNAME_LIST[@]} GPU devices for Wayland client"
for DEVICE in "${DEVNAME_LIST[@]}"; do
if ! jq -e ".linux.devices[] | select(.path == \"$DEVICE\")" <<< "$CONTAINER_CONFIG" > /dev/null 2>&1; then
# shellcheck disable=SC2012
NEW_DEVICE=$(jq -n --arg path "$DEVICE" \
--arg dev_type "$(ls -l "$DEVICE" | head -c 1)" \
--arg major "$(printf "%d" "$(stat -c "%#t" "$DEVICE")")" \
--arg minor "$(printf "%d" "$(stat -c "%#T" "$DEVICE")")" \
--arg filemode "$(printf "%d" "$(stat -c '02%#a' "$DEVICE")")" \
--arg uid "$(stat -c "%u" "$DEVICE")" \
--arg gid "$(stat -c "%g" "$DEVICE")" \
'{
if ! jq -e ".linux.devices[] | select(.path == \"$DEVICE\")" <<<"$CONTAINER_CONFIG" >/dev/null 2>&1; then
# Get device info using device support library
if ! device_info=$(get_device_info "$DEVICE"); then
log "WARNING" "Failed to get device info for $DEVICE, skipping"
continue
fi
# Parse device info: type:major:minor:file_mode:uid:gid
IFS=':' read -r dev_type major minor filemode uid gid <<<"$device_info"
NEW_DEVICE=$(jq -n --arg path "$DEVICE" \
--arg dev_type "$dev_type" \
--arg major "$major" \
--arg minor "$minor" \
--arg filemode "$filemode" \
--arg uid "$uid" \
--arg gid "$gid" \
'{
"path": $path,
"type": $dev_type,
"major": $major|tonumber,
@ -69,12 +86,11 @@ if [ ${#DEVNAME_LIST[@]} -gt 0 ]; then
"gid": $gid|tonumber,
}')
# shellcheck disable=SC2012
NEW_DEV_RESOURCE=$(jq -n \
--arg dev_type "$(ls -l "$DEVICE" | head -c 1)" \
--arg major "$(printf "%d" "$(stat -c "%#t" "$DEVICE")")" \
--arg minor "$(printf "%d" "$(stat -c "%#T" "$DEVICE")")" \
'{
NEW_DEV_RESOURCE=$(jq -n \
--arg dev_type "$dev_type" \
--arg major "$major" \
--arg minor "$minor" \
'{
"allow": true,
"type": $dev_type,
"major": $major|tonumber,
@ -82,8 +98,8 @@ if [ ${#DEVNAME_LIST[@]} -gt 0 ]; then
"access": "rwm"
}')
CONTAINER_CONFIG=$(jq ".linux.devices += [$NEW_DEVICE]" <<< "$CONTAINER_CONFIG")
CONTAINER_CONFIG=$(jq ".linux.resources.devices += [$NEW_DEV_RESOURCE]" <<< "$CONTAINER_CONFIG")
CONTAINER_CONFIG=$(jq ".linux.devices += [$NEW_DEVICE]" <<<"$CONTAINER_CONFIG")
CONTAINER_CONFIG=$(jq ".linux.resources.devices += [$NEW_DEV_RESOURCE]" <<<"$CONTAINER_CONFIG")
log "INFO" "Added GPU device: $DEVICE"
else
log "INFO" "GPU device already exists in spec: $DEVICE"
@ -105,4 +121,3 @@ log "INFO" "Total devices in final spec: $total_devices"
log "INFO" "QM Wayland Client Devices hook completed successfully"
echo "$CONTAINER_CONFIG" | jq .

View File

@ -1,70 +0,0 @@
# Wayland-session-devices
The wayland-session-devices OCI hook enables containers to access Wayland display server devices in a multi-seat
environment managed by systemd-logind.
## Key Functionality
1. **Device Discovery**: The hook automatically discovers and configures access to hardware devices associated with a
specific systemd-logind seat, including:
- Input devices (keyboard, mouse, touchpad, etc.)
- Render devices (GPU hardware acceleration)
- Display-related devices
2. **Container Configuration**: It dynamically modifies the container's OCI configuration to include the necessary
device permissions and access controls for Wayland compositor functionality.
3. **Comprehensive Logging**: All operations are logged to `/var/log/qm-wayland-session-devices.log` for monitoring and debugging.
## Configuration
The hook supports the following container annotation:
- `org.containers.qm.wayland.seat`: Specifies which systemd-logind seat devices should be made available to the
container. This annotation would be used in the QM container and the wayland compositor container
(running as a nested container inside the QM container) to have access to the required devices.
The value should match a valid seat name configured in the system's multi-seat infrastructure.
## Logging
The hook provides detailed logging of all operations:
- **Log File**: `/var/log/qm-wayland-session-devices.log`
- **Log Format**: `YYYY-MM-DD HH:MM:SS - qm-wayland-session-devices - LEVEL - MESSAGE`
- **Log Levels**: INFO, WARNING, ERROR
### Example Log Output
```text
2024-01-15 10:30:45 - qm-wayland-session-devices - INFO - Processing Wayland seat annotation: seat0
2024-01-15 10:30:45 - qm-wayland-session-devices - INFO - Found seat system devices for seat0
2024-01-15 10:30:45 - qm-wayland-session-devices - INFO - Adding seat device: /dev/input/event0
2024-01-15 10:30:45 - qm-wayland-session-devices - INFO - Adding input device: /dev/input/mouse0
2024-01-15 10:30:45 - qm-wayland-session-devices - INFO - Adding render device: /dev/dri/renderD128
2024-01-15 10:30:45 - qm-wayland-session-devices - INFO - Processing 5 devices for Wayland seat seat0
2024-01-15 10:30:45 - qm-wayland-session-devices - INFO - Added Wayland seat device: /dev/input/event0
2024-01-15 10:30:45 - qm-wayland-session-devices - INFO - Total devices in final spec: 5
2024-01-15 10:30:45 - qm-wayland-session-devices - INFO - QM Wayland Session Devices hook completed successfully
```
## Example Configuration
To use the Wayland-session-devices hook, you can create a dropin configuration file to add the necessary annotation
to your qm.container:
1. Create a dropin directory and file:
```bash
mkdir -p /etc/containers/systemd/qm.container.d/
```
2. Create a dropin file (e.g., `wayland.conf`):
```ini
[Container]
Annotation=org.containers.qm.wayland.seat=seat0
```
The same procedure would be done in the wayland compositor container, running inside the qm container as nested
container, by adding a similar dropin file.

View File

@ -1,125 +0,0 @@
#!/bin/bash
set -euo pipefail
# Configuration
LOGFILE="/var/log/qm-wayland-session-devices.log"
HOOK_NAME="qm-wayland-session-devices"
# Logging function
log() {
local level="$1"
shift
echo "$(date '+%Y-%m-%d %H:%M:%S') - $HOOK_NAME - $level - $*" >> "$LOGFILE"
if [[ "$level" == "ERROR" ]]; then
echo "$(date '+%Y-%m-%d %H:%M:%S') - $HOOK_NAME - $level - $*" >&2
fi
}
input="-"
CONTAINER_CONFIG=$(cat "$input")
if [[ -z "$CONTAINER_CONFIG" ]]; then
log "ERROR" "Failed to read OCI spec from stdin"
exit 1
fi
SEAT=$(echo "$CONTAINER_CONFIG" | jq -r '.annotations["org.containers.qm.wayland.seat"] // empty')
DEVNAME_LIST=()
if [ -n "$SEAT" ]; then
log "INFO" "Processing Wayland seat annotation: $SEAT"
# Extract and resolve all the devices associated to a systemd-logind seat
SEAT_SYS_DEVICE_LIST=$(loginctl seat-status "$SEAT" | grep -oP '/sys\S+')
log "INFO" "Found seat system devices for $SEAT"
for DEVICE in $SEAT_SYS_DEVICE_LIST; do
DEVNAME=$(udevadm info -x "$DEVICE" | grep -oP '^E: DEVNAME=\K.*')
if [ -n "$DEVNAME" ]; then
DEVNAME_LIST+=("$DEVNAME")
log "INFO" "Adding seat device: $DEVNAME"
fi
done
# Find all the input devices available
INPUT_DEVICES=$(find /dev/input -type c \( -regex ".*/event[0-9]+" -o -regex ".*/mice[0-9]*" -o -regex ".*/mouse[0-9]+" \))
for INPUT_DEVICE in $INPUT_DEVICES; do
DEVNAME_LIST+=("$INPUT_DEVICE")
log "INFO" "Adding input device: $INPUT_DEVICE"
done
# Find all the render devices available
RENDER_DEVICES=$(find /dev/dri -type c \( -regex ".*/render.*" \))
for RENDER_DEVICE in $RENDER_DEVICES; do
DEVNAME_LIST+=("$RENDER_DEVICE")
log "INFO" "Adding render device: $RENDER_DEVICE"
done
# Check if .linux.devices exists and is a list in $CONTAINER_CONFIG
if ! jq -e '.linux.devices | arrays' <<< "$CONTAINER_CONFIG" > /dev/null 2>&1; then
# Create an empty .linux.devices list if it does not exist
CONTAINER_CONFIG=$(jq '.linux.devices = []' <<< "$CONTAINER_CONFIG")
log "INFO" "Initialized empty devices list in OCI spec"
fi
# Iterate over the DEVNAME_LIST to include the required information in the CONTAINER_CONFIG
log "INFO" "Processing ${#DEVNAME_LIST[@]} devices for Wayland seat $SEAT"
for DEVICE in "${DEVNAME_LIST[@]}"; do
if ! jq -e ".linux.devices[] | select(.path == \"$DEVICE\")" <<< "$CONTAINER_CONFIG" > /dev/null 2>&1; then
# shellcheck disable=SC2012
NEW_DEVICE=$(jq -n --arg path "$DEVICE" \
--arg dev_type "$(ls -l "$DEVICE" | head -c 1)" \
--arg major "$(printf "%d" "$(stat -c "%#t" "$DEVICE")")" \
--arg minor "$(printf "%d" "$(stat -c "%#T" "$DEVICE")")" \
--arg filemode "$(printf "%d" "$(stat -c '02%#a' "$DEVICE")")" \
--arg uid "$(stat -c "%u" "$DEVICE")" \
--arg gid "$(stat -c "%g" "$DEVICE")" \
'{
"path": $path,
"type": $dev_type,
"major": $major|tonumber,
"minor": $minor|tonumber,
"fileMode": $filemode|tonumber,
"uid": $uid|tonumber,
"gid": $gid|tonumber,
}')
# shellcheck disable=SC2012
NEW_DEV_RESOURCE=$(jq -n \
--arg dev_type "$(ls -l "$DEVICE" | head -c 1)" \
--arg major "$(printf "%d" "$(stat -c "%#t" "$DEVICE")")" \
--arg minor "$(printf "%d" "$(stat -c "%#T" "$DEVICE")")" \
'{
"allow": true,
"type": $dev_type,
"major": $major|tonumber,
"minor": $minor|tonumber,
"access": "rwm"
}')
CONTAINER_CONFIG=$(jq ".linux.devices += [$NEW_DEVICE]" <<< "$CONTAINER_CONFIG")
CONTAINER_CONFIG=$(jq ".linux.resources.devices += [$NEW_DEV_RESOURCE]" <<< "$CONTAINER_CONFIG")
log "INFO" "Added Wayland seat device: $DEVICE"
else
log "INFO" "Device already exists in spec: $DEVICE"
fi
done
log "INFO" "Successfully processed all Wayland seat devices for $SEAT"
else
log "INFO" "No Wayland seat annotation found"
fi
# Initialize log file directory
mkdir -p "$(dirname "$LOGFILE")"
touch "$LOGFILE"
# Count total devices in final spec
total_devices=$(echo "$CONTAINER_CONFIG" | jq '.linux.devices | length // 0')
log "INFO" "Total devices in final spec: $total_devices"
log "INFO" "QM Wayland Session Devices hook completed successfully"
echo "$CONTAINER_CONFIG" | jq .

View File

@ -1,12 +0,0 @@
{
"version": "1.0.0",
"hook": {
"path": "/usr/libexec/oci/hooks.d/oci-qm-wayland-session-devices"
},
"when": {
"annotations": {
"^org\\.containers\\.qm\\.wayland\\..*$": "^.*$"
}
},
"stages": ["precreate"]
}

View File

@ -1,5 +1,8 @@
%global debug_package %{nil}
# Define path macro for QM rootfs
%global qm_rootfs_prefix /usr/lib/qm/rootfs
Name: qm-oci-hooks
Version: %{version}
Release: 1%{?dist}
@ -20,7 +23,6 @@ both on the host system and inside the QM rootfs to support nested containers.
Included hooks:
- qm-device-manager: Dynamic device mounting based on container annotations
- wayland-session-devices: Wayland display server device access for multi-seat
- wayland-client-devices: GPU hardware acceleration for Wayland clients
The hooks are available in two locations:
@ -36,12 +38,14 @@ The hooks are available in two locations:
%install
# Create OCI hook directories
install -d %{buildroot}%{_libexecdir}/oci/hooks.d
install -d %{buildroot}%{_libexecdir}/oci/lib
install -d %{buildroot}%{_datadir}/containers/oci/hooks.d
# Note: QM rootfs directories (/usr/lib/qm/rootfs/*) are handled by ghost directories in main qm package
# We only need to create the specific directories we're installing into
install -d %{buildroot}/usr/lib/qm/rootfs%{_libexecdir}/oci/hooks.d
install -d %{buildroot}/usr/lib/qm/rootfs%{_datadir}/containers/oci/hooks.d
install -d %{buildroot}%{qm_rootfs_prefix}%{_libexecdir}/oci/hooks.d
install -d %{buildroot}%{qm_rootfs_prefix}%{_libexecdir}/oci/lib
install -d %{buildroot}%{qm_rootfs_prefix}%{_datadir}/containers/oci/hooks.d
# Install QM Device Manager hook
install -m 755 %{_builddir}/qm-oci-hooks-%{version}/oci-hooks/qm-device-manager/oci-qm-device-manager \
@ -49,15 +53,9 @@ install -m 755 %{_builddir}/qm-oci-hooks-%{version}/oci-hooks/qm-device-manager/
install -m 644 %{_builddir}/qm-oci-hooks-%{version}/oci-hooks/qm-device-manager/oci-qm-device-manager.json \
%{buildroot}%{_datadir}/containers/oci/hooks.d/oci-qm-device-manager.json
install -m 755 %{_builddir}/qm-oci-hooks-%{version}/oci-hooks/qm-device-manager/oci-qm-device-manager \
%{buildroot}/usr/lib/qm/rootfs%{_libexecdir}/oci/hooks.d/oci-qm-device-manager
%{buildroot}%{qm_rootfs_prefix}%{_libexecdir}/oci/hooks.d/oci-qm-device-manager
install -m 644 %{_builddir}/qm-oci-hooks-%{version}/oci-hooks/qm-device-manager/oci-qm-device-manager.json \
%{buildroot}/usr/lib/qm/rootfs%{_datadir}/containers/oci/hooks.d/oci-qm-device-manager.json
# Install Wayland Session Devices hook
install -m 755 %{_builddir}/qm-oci-hooks-%{version}/oci-hooks/wayland-session-devices/oci-qm-wayland-session-devices \
%{buildroot}%{_libexecdir}/oci/hooks.d/oci-qm-wayland-session-devices
install -m 644 %{_builddir}/qm-oci-hooks-%{version}/oci-hooks/wayland-session-devices/oci-qm-wayland-session-devices.json \
%{buildroot}%{_datadir}/containers/oci/hooks.d/oci-qm-wayland-session-devices.json
%{buildroot}%{qm_rootfs_prefix}%{_datadir}/containers/oci/hooks.d/oci-qm-device-manager.json
# Install Wayland Client Devices hook
install -m 755 %{_builddir}/qm-oci-hooks-%{version}/oci-hooks/wayland-client-devices/oci-qm-wayland-client-devices \
@ -65,16 +63,24 @@ install -m 755 %{_builddir}/qm-oci-hooks-%{version}/oci-hooks/wayland-client-dev
install -m 644 %{_builddir}/qm-oci-hooks-%{version}/oci-hooks/wayland-client-devices/oci-qm-wayland-client-devices.json \
%{buildroot}%{_datadir}/containers/oci/hooks.d/oci-qm-wayland-client-devices.json
install -m 755 %{_builddir}/qm-oci-hooks-%{version}/oci-hooks/wayland-client-devices/oci-qm-wayland-client-devices \
%{buildroot}/usr/lib/qm/rootfs%{_libexecdir}/oci/hooks.d/oci-qm-wayland-client-devices
%{buildroot}%{qm_rootfs_prefix}%{_libexecdir}/oci/hooks.d/oci-qm-wayland-client-devices
install -m 644 %{_builddir}/qm-oci-hooks-%{version}/oci-hooks/wayland-client-devices/oci-qm-wayland-client-devices.json \
%{buildroot}/usr/lib/qm/rootfs%{_datadir}/containers/oci/hooks.d/oci-qm-wayland-client-devices.json
%{buildroot}%{qm_rootfs_prefix}%{_datadir}/containers/oci/hooks.d/oci-qm-wayland-client-devices.json
# Install common libraries
install -m 644 %{_builddir}/qm-oci-hooks-%{version}/oci-hooks/lib/common.sh \
%{buildroot}%{_libexecdir}/oci/lib/common.sh
install -m 644 %{_builddir}/qm-oci-hooks-%{version}/oci-hooks/lib/device-support.sh \
%{buildroot}%{_libexecdir}/oci/lib/device-support.sh
install -m 644 %{_builddir}/qm-oci-hooks-%{version}/oci-hooks/lib/common.sh \
%{buildroot}%{qm_rootfs_prefix}%{_libexecdir}/oci/lib/common.sh
install -m 644 %{_builddir}/qm-oci-hooks-%{version}/oci-hooks/lib/device-support.sh \
%{buildroot}%{qm_rootfs_prefix}%{_libexecdir}/oci/lib/device-support.sh
# Create documentation directory and install component-specific README files with unique names
install -d %{buildroot}%{_docdir}/qm-oci-hooks
install -m 644 %{_builddir}/qm-oci-hooks-%{version}/oci-hooks/qm-device-manager/README.md \
%{buildroot}%{_docdir}/qm-oci-hooks/README-qm-device-manager.md
install -m 644 %{_builddir}/qm-oci-hooks-%{version}/oci-hooks/wayland-session-devices/README.md \
%{buildroot}%{_docdir}/qm-oci-hooks/README-wayland-session-devices.md
install -m 644 %{_builddir}/qm-oci-hooks-%{version}/oci-hooks/wayland-client-devices/README.md \
%{buildroot}%{_docdir}/qm-oci-hooks/README-wayland-client-devices.md
@ -82,30 +88,32 @@ install -m 644 %{_builddir}/qm-oci-hooks-%{version}/oci-hooks/wayland-client-dev
%license LICENSE
%doc CODE-OF-CONDUCT.md README.md SECURITY.md
%{_docdir}/qm-oci-hooks/README-qm-device-manager.md
%{_docdir}/qm-oci-hooks/README-wayland-session-devices.md
%{_docdir}/qm-oci-hooks/README-wayland-client-devices.md
# OCI hook executables (host system)
# OCI hook executables and libraries (host system)
%dir %{_libexecdir}/oci
%dir %{_libexecdir}/oci/hooks.d
%dir %{_libexecdir}/oci/lib
%{_libexecdir}/oci/hooks.d/oci-qm-device-manager
%{_libexecdir}/oci/hooks.d/oci-qm-wayland-session-devices
%{_libexecdir}/oci/hooks.d/oci-qm-wayland-client-devices
%{_libexecdir}/oci/lib/common.sh
%{_libexecdir}/oci/lib/device-support.sh
# OCI hook configurations (host system)
%dir %{_datadir}/containers/oci
%dir %{_datadir}/containers/oci/hooks.d
%{_datadir}/containers/oci/hooks.d/oci-qm-device-manager.json
%{_datadir}/containers/oci/hooks.d/oci-qm-wayland-session-devices.json
%{_datadir}/containers/oci/hooks.d/oci-qm-wayland-client-devices.json
# OCI hook executables (QM rootfs for nested containers)
/usr/lib/qm/rootfs%{_libexecdir}/oci/hooks.d/oci-qm-device-manager
/usr/lib/qm/rootfs%{_libexecdir}/oci/hooks.d/oci-qm-wayland-client-devices
# OCI hook executables and libraries (QM rootfs for nested containers)
%{qm_rootfs_prefix}%{_libexecdir}/oci/hooks.d/oci-qm-device-manager
%{qm_rootfs_prefix}%{_libexecdir}/oci/hooks.d/oci-qm-wayland-client-devices
%{qm_rootfs_prefix}%{_libexecdir}/oci/lib/common.sh
%{qm_rootfs_prefix}%{_libexecdir}/oci/lib/device-support.sh
# OCI hook configurations (QM rootfs for nested containers)
/usr/lib/qm/rootfs%{_datadir}/containers/oci/hooks.d/oci-qm-device-manager.json
/usr/lib/qm/rootfs%{_datadir}/containers/oci/hooks.d/oci-qm-wayland-client-devices.json
%{qm_rootfs_prefix}%{_datadir}/containers/oci/hooks.d/oci-qm-device-manager.json
%{qm_rootfs_prefix}%{_datadir}/containers/oci/hooks.d/oci-qm-wayland-client-devices.json
%changelog
* Fri Jul 21 2023 RH Container Bot <rhcontainerbot@fedoraproject.org>

2
setup
View File

@ -20,7 +20,7 @@ AGENT_HOSTNAME="$(hostname)"
AGENTCONF="/etc/bluechi/agent.conf.d/agent.conf"
QM_CONTAINER_IDS=1000000000:1500000000
CONTAINER_IDS=2500000000:1500000000
PACKAGES_TO_INSTALL="selinux-policy-targeted podman systemd procps-ng iptables-nft jq"
PACKAGES_TO_INSTALL="selinux-policy-targeted podman systemd procps-ng iptables-nft jq hostname"
# RHEL kernel uses iptables-nft, not iptables-legacy, the tool should
# make sure this package is removed.

View File

@ -23,12 +23,11 @@ dist: ## - Creates the QM OCI hooks package
../qm/oci-hooks/qm-device-manager/oci-qm-device-manager \
../qm/oci-hooks/qm-device-manager/oci-qm-device-manager.json \
../qm/oci-hooks/qm-device-manager/README.md \
../qm/oci-hooks/wayland-session-devices/oci-qm-wayland-session-devices \
../qm/oci-hooks/wayland-session-devices/oci-qm-wayland-session-devices.json \
../qm/oci-hooks/wayland-session-devices/README.md \
../qm/oci-hooks/wayland-client-devices/oci-qm-wayland-client-devices \
../qm/oci-hooks/wayland-client-devices/oci-qm-wayland-client-devices.json \
../qm/oci-hooks/wayland-client-devices/README.md
../qm/oci-hooks/wayland-client-devices/README.md \
../qm/oci-hooks/lib/common.sh \
../qm/oci-hooks/lib/device-support.sh
cd $(ROOTDIR) && mv /tmp/qm-oci-hooks-${VERSION}.tar.gz ./rpm
.PHONY: qm-oci-hooks

View File

@ -51,6 +51,13 @@ disk_cleanup() {
reload_config() {
exec_cmd "systemctl daemon-reload"
exec_cmd "systemctl restart qm"
# Add verification loop for qm status
if timeout 30 bash -c "until systemctl is-active qm; do sleep 1; done"; then
info_message "PASS: Service QM is Active."
else
info_message "FAIL: Service QM is not Active"
exit 1
fi
}
running_container_in_qm() {

View File

@ -19,16 +19,16 @@ setup_qm_oci_hooks_from_source() {
exec_cmd "cp ${TMT_TREE}/oci-hooks/qm-device-manager/oci-qm-device-manager $hooks_exec_dir/"
exec_cmd "chmod +x $hooks_exec_dir/oci-qm-device-manager"
# Copy Wayland Session Devices hook
exec_cmd "cp ${TMT_TREE}/oci-hooks/wayland-session-devices/oci-qm-wayland-session-devices.json $hooks_config_dir/"
exec_cmd "cp ${TMT_TREE}/oci-hooks/wayland-session-devices/oci-qm-wayland-session-devices $hooks_exec_dir/"
exec_cmd "chmod +x $hooks_exec_dir/oci-qm-wayland-session-devices"
# Copy Wayland Client Devices hook
exec_cmd "cp ${TMT_TREE}/oci-hooks/wayland-client-devices/oci-qm-wayland-client-devices.json $hooks_config_dir/"
exec_cmd "cp ${TMT_TREE}/oci-hooks/wayland-client-devices/oci-qm-wayland-client-devices $hooks_exec_dir/"
exec_cmd "chmod +x $hooks_exec_dir/oci-qm-wayland-client-devices"
# Copy essential library files (excluding mock support)
exec_cmd "mkdir -p $hooks_exec_dir/../lib"
exec_cmd "cp ${TMT_TREE}/oci-hooks/lib/common.sh $hooks_exec_dir/../lib/"
exec_cmd "cp ${TMT_TREE}/oci-hooks/lib/device-support.sh $hooks_exec_dir/../lib/"
info_message "setup_qm_oci_hooks_from_source(): All OCI hooks copied successfully from TMT_TREE"
}
@ -41,17 +41,20 @@ cleanup_oci_hooks() {
# Remove test hooks
rm -f "$hooks_config_dir/oci-qm-device-manager.json" || true
rm -f "$hooks_config_dir/oci-qm-wayland-session-devices.json" || true
rm -f "$hooks_config_dir/oci-qm-wayland-client-devices.json" || true
rm -f "$hooks_exec_dir/oci-qm-device-manager" || true
rm -f "$hooks_exec_dir/oci-qm-wayland-session-devices" || true
rm -f "$hooks_exec_dir/oci-qm-wayland-client-devices" || true
# Clean up test container
local test_container="qm-oci-hooks-sanity-test"
systemctl stop "$test_container" 2>/dev/null || true
rm -f "/etc/containers/systemd/${test_container}.container" || true
# Clean up library files
rm -rf "$hooks_exec_dir/../lib" || true
# Clean up test containers
local test_containers=("qm-oci-hooks-device-test")
for test_container in "${test_containers[@]}"; do
systemctl stop "$test_container" 2>/dev/null || true
rm -f "/etc/containers/systemd/${test_container}.container" || true
done
systemctl daemon-reload 2>/dev/null || true
}
@ -65,10 +68,29 @@ check_qm_oci_hooks_are_ok(){
# Setup hooks directly from source tree
setup_qm_oci_hooks_from_source
# Test OCI hook functionality with a container using device annotation
local test_container="qm-oci-hooks-sanity-test"
# Validate hook JSON configurations
info_message "check_qm_oci_hooks_are_ok(): Validating hook JSON configurations"
local hooks_config_dir="/usr/share/containers/oci/hooks.d"
if command -v jq > /dev/null 2>&1; then
for hook_json in "$hooks_config_dir"/*.json; do
if [[ -f "$hook_json" ]]; then
if jq . "$hook_json" > /dev/null 2>&1; then
info_message "check_qm_oci_hooks_are_ok(): Valid JSON: $(basename "$hook_json")"
else
info_message "FAIL: check_qm_oci_hooks_are_ok(): Invalid JSON: $(basename "$hook_json")"
exit 1
fi
fi
done
else
info_message "check_qm_oci_hooks_are_ok(): jq not available, skipping JSON validation"
fi
# Test QM Device Manager hook with TTY devices
local test_container="qm-oci-hooks-device-test"
# Create a test container with ttys annotation (simple device test)
cat > "/tmp/${test_container}.container" << EOF
[Unit]
Description=OCI Hooks Sanity Test Container
@ -100,6 +122,11 @@ EOF
info_message "check_qm_oci_hooks_are_ok(): OCI hook processing detected in logs"
fi
fi
# Clean up test container
systemctl stop "$test_container" 2>/dev/null || true
rm -f "/etc/containers/systemd/${test_container}.container" || true
else
info_message "FAIL: check_qm_oci_hooks_are_ok(): Quadlet container with OCI hook annotation is not active"
exit 1
@ -109,7 +136,8 @@ EOF
exit 1
fi
info_message "PASS: check_qm_oci_hooks_are_ok()"
exec_cmd "systemctl daemon-reload"
info_message "PASS: check_qm_oci_hooks_are_ok(): QM Device Manager hook tested successfully"
}
check_qm_oci_hooks_are_ok