mirror of https://github.com/containers/qm.git
Compare commits
6 Commits
Author | SHA1 | Date |
---|---|---|
|
c9868e8507 | |
|
5938fa91fe | |
|
3c960da3cc | |
|
1e6111f2d4 | |
|
c84b01d598 | |
|
08fb1c1a66 |
|
@ -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
|
8
Makefile
8
Makefile
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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:
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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"]
|
||||
}
|
||||
|
|
|
@ -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/
|
|
@ -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]+$
|
|
@ -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
|
|
@ -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)
|
|
@ -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)
|
||||
)
|
|
@ -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
|
|
@ -0,0 +1,5 @@
|
|||
# Basic testing framework
|
||||
pytest>=7.0.0
|
||||
|
||||
# Test fixtures and utilities
|
||||
jsonschema>=4.0.0
|
|
@ -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"
|
|
@ -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"
|
|
@ -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}"
|
||||
)
|
|
@ -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"
|
|
@ -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"
|
|
@ -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"
|
|
@ -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}"
|
|
@ -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"
|
|
@ -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 []
|
|
@ -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"
|
|
@ -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
|
|
@ -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 .
|
||||
|
||||
|
|
|
@ -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.
|
|
@ -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 .
|
|
@ -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"]
|
||||
}
|
|
@ -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
2
setup
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue