diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..e53914a --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,24 @@ +name: Lint + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + lint: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + + - name: Install ShellCheck + run: sudo apt-get update && sudo apt-get install -y shellcheck + + - name: Check syntax + run: bash -n srv-ctl.sh lib/os-utils.sh lib/storage.sh + + - name: Run ShellCheck + run: | + cp tests/fixtures/config.local.test config.local + shellcheck -x srv-ctl.sh lib/os-utils.sh lib/storage.sh diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml new file mode 100644 index 0000000..c80aaed --- /dev/null +++ b/.github/workflows/test-e2e.yml @@ -0,0 +1,54 @@ +name: E2E Tests (VM) + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + vm: + name: ${{ matrix.os }} + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + # Only test OSes with cryptsetup >=2.4.0 (BitLocker support): Debian 12+, Ubuntu 22.04+ + os: [debian-12, debian-13, ubuntu-22.04, ubuntu-24.04] + steps: + - uses: actions/checkout@v4 + + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Install dependencies + run: sudo apt-get update && sudo apt-get install -y qemu-system-x86 qemu-utils cloud-image-utils + + - name: Cache VM images + uses: actions/cache@v4 + with: + path: ~/.cache/vm-images + key: vm-image-${{ matrix.os }} + + - name: Download cloud image + run: | + mkdir -p ~/.cache/vm-images + ./tests/vm/download-image.sh ${{ matrix.os }} + + - name: Run E2E tests (${{ matrix.os }}) + timeout-minutes: 15 + run: ./tests/vm/run-e2e-tests.sh ${{ matrix.os }} + + - name: Upload results + if: always() + uses: actions/upload-artifact@v4 + with: + name: e2e-results-${{ matrix.os }} + path: tests/vm/results/ + + - name: Cleanup + if: always() + run: ./tests/vm/cleanup.sh diff --git a/.github/workflows/test-integration-vm.yml b/.github/workflows/test-integration-vm.yml new file mode 100644 index 0000000..1f32e66 --- /dev/null +++ b/.github/workflows/test-integration-vm.yml @@ -0,0 +1,54 @@ +name: Integration Tests + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + vm: + name: ${{ matrix.os }} + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + # Only test OSes with cryptsetup >=2.4.0 (BitLocker support): Debian 12+, Ubuntu 22.04+ + os: [debian-12, debian-13, ubuntu-22.04, ubuntu-24.04] + steps: + - uses: actions/checkout@v4 + + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Install dependencies + run: sudo apt-get update && sudo apt-get install -y qemu-system-x86 qemu-utils cloud-image-utils + + - name: Cache VM images + uses: actions/cache@v4 + with: + path: ~/.cache/vm-images + key: vm-image-${{ matrix.os }} + + - name: Download cloud image + run: | + mkdir -p ~/.cache/vm-images + ./tests/vm/download-image.sh ${{ matrix.os }} + + - name: Run integration tests (${{ matrix.os }}) + timeout-minutes: 15 + run: ./tests/vm/run-tests.sh ${{ matrix.os }} + + - name: Upload results + if: always() + uses: actions/upload-artifact@v4 + with: + name: results-${{ matrix.os }} + path: tests/vm/results/ + + - name: Cleanup + if: always() + run: ./tests/vm/cleanup.sh diff --git a/.github/workflows/test-unit.yml b/.github/workflows/test-unit.yml new file mode 100644 index 0000000..4d380ba --- /dev/null +++ b/.github/workflows/test-unit.yml @@ -0,0 +1,23 @@ +name: Unit Tests + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + unit: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + + - name: Install bats + run: | + sudo apt-get update + sudo apt-get install -y git curl + git clone --branch v1.13.0 --depth 1 https://github.com/bats-core/bats-core.git /tmp/bats-core + sudo /tmp/bats-core/install.sh /usr/local + + - name: Run unit tests + run: bats tests/unit/*.bats diff --git a/.gitignore b/.gitignore index ca8605f..15e5cf4 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,6 @@ config.local +config.local.* + +# Test artifacts +tests/vm/results/ +tests/vm/work/ diff --git a/README.md b/README.md index 7553e94..ee2fe75 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,12 @@ Small utility to manage home server services dependent on encrypted storage. ## Requirements - **cryptsetup**: Version 2.4.0+ (supports both LUKS and BitLocker encryption) + - Note: cryptsetup >=2.4.0 is only available in Debian 12+ and Ubuntu 22.04+. + - BitLocker support and all E2E/integration tests require these or newer OS versions. - **lvm2**: Required only if using LVM volumes -- **Root privileges**: Script must be run as root +- **GNU coreutils**: Required for version comparison (`sort -V`) +- **systemd**: Required for service management +- **Root privileges**: Script must be run as root for start/stop/unlock operations ## Configuration @@ -35,7 +39,6 @@ readonly ST_USER_2="bob" # Service names (constructed automatically) readonly ST_SERVICE_1="syncthing@${ST_USER_1}.service" readonly ST_SERVICE_2="syncthing@${ST_USER_2}.service" -readonly DOCKER_SERVICE="docker.service" ``` ### Storage Device Configuration @@ -47,17 +50,26 @@ Each storage device supports: - **LVM Support**: Optional logical volume management - **Encryption Type**: Either `luks` or `bitlocker` - **Key Files**: Optional for automated unlocking +- **Ownership**: Optional user/group for mount point +- **Mount Options**: Additional filesystem mount options -Example for BitLocker device: +Example for a BitLocker device (all fields shown): ```bash -readonly STORAGE_2A_MOUNT="storage2a" -readonly STORAGE_2A_MAPPER="storage2a-data" -readonly STORAGE_2A_UUID="your-device-uuid" -readonly STORAGE_2A_KEY_FILE="/path/to/recovery.key" -readonly STORAGE_2A_ENCRYPTION_TYPE="bitlocker" +readonly STORAGE_2A_MOUNT="storage2a" # Mount point under /mnt/ +readonly STORAGE_2A_MAPPER="storage2a-data" # Device mapper name +readonly STORAGE_2A_LVM_NAME="none" # LVM volume name ("none" to disable) +readonly STORAGE_2A_LVM_GROUP="vg-srv" # LVM group (used if LVM enabled) +readonly STORAGE_2A_UUID="your-device-uuid" # Device UUID (find with: sudo blkid) +readonly STORAGE_2A_KEY_FILE="/path/to/key" # Key file path ("none" for interactive) +readonly STORAGE_2A_ENCRYPTION_TYPE="bitlocker" # "luks" or "bitlocker" +readonly STORAGE_2A_OWNER_USER="sync_srv" # Mount ownership user ("none" to skip) +readonly STORAGE_2A_OWNER_GROUP="sync_srv" # Mount ownership group ("none" to skip) +readonly STORAGE_2A_MOUNT_OPTIONS="defaults" # Additional mount options ``` +> **Note**: See `config.local.template` for the complete list of all configurable devices and their default values. + ## Usage ```bash @@ -65,10 +77,12 @@ sudo ./srv-ctl.sh start # Start all services and mount devices sudo ./srv-ctl.sh stop # Stop all services and unmount devices sudo ./srv-ctl.sh unlock-only # Only unlock and mount devices sudo ./srv-ctl.sh stop-services-only # Only stop services -./srv-ctl.sh validate-config # Validate configuration without making changes +./srv-ctl.sh validate-config # Validate configuration (no root required) ./srv-ctl.sh help # Show help message ``` +**Note**: The `validate-config` command does not require root privileges unless key files have restricted permissions. + ## Migration from Old Format If you have an existing `config.local` from an earlier version, you'll need to update it to the new format. The main changes: @@ -87,3 +101,34 @@ If you have an existing `config.local` from an earlier version, you'll need to u - Enhanced validation and error handling Use `./srv-ctl.sh validate-config` to check your configuration after updating. + +## Development & Testing + +The project includes comprehensive tests with VM-based testing: + +```bash +# Run local tests (no root required) +./tests/run-tests.sh + +# Run full VM tests (CI only, multi-OS) +./tests/vm/run-vm-tests.sh ubuntu-22.04 +``` + +See [`tests/README.md`](tests/README.md) for detailed testing documentation. + +## Project Structure + +``` +srv-ctl/ +├── srv-ctl.sh # Main script +├── lib/ +│ ├── os-utils.sh # OS-level utilities +│ └── storage.sh # Storage operations +├── config.local.template # Configuration template +└── tests/ # Test suite +``` + +## License + +See repository for license information. + diff --git a/config.local.template b/config.local.template index ccaad1f..6993374 100644 --- a/config.local.template +++ b/config.local.template @@ -9,9 +9,9 @@ readonly CRYPTSETUP_MIN_VERSION="2.4.0" readonly ST_USER_1="none" # Set to username to enable (e.g., "alice") readonly ST_USER_2="none" # Set to username to enable (e.g., "bob") -# Service names (automatically constructed) -readonly ST_SERVICE_1="${ST_USER_1:+syncthing@${ST_USER_1}.service}" -readonly ST_SERVICE_2="${ST_USER_2:+syncthing@${ST_USER_2}.service}" +# Service names (automatically constructed from user names) +readonly ST_SERVICE_1=$([ "$ST_USER_1" != "none" ] && echo "syncthing@${ST_USER_1}.service" || echo "none") +readonly ST_SERVICE_2=$([ "$ST_USER_2" != "none" ] && echo "syncthing@${ST_USER_2}.service" || echo "none") readonly DOCKER_SERVICE="none" # Set to "docker.service" to enable # ----------------------------------------------------------------------------- @@ -25,6 +25,9 @@ readonly PRIMARY_DATA_LVM_GROUP="vg-srv" # LVM group name (used if LVM volume i readonly PRIMARY_DATA_UUID="none" # Set to device UUID to enable (find with: sudo blkid) readonly PRIMARY_DATA_KEY_FILE="none" # Set to key file path for automated unlock readonly PRIMARY_DATA_ENCRYPTION_TYPE="luks" # Options: "luks" or "bitlocker" +readonly PRIMARY_DATA_OWNER_USER="none" # Set to username for mount ownership (e.g., "sync_srv") +readonly PRIMARY_DATA_OWNER_GROUP="none" # Set to group name for mount ownership (e.g., "sync_srv") +readonly PRIMARY_DATA_MOUNT_OPTIONS="defaults" # Additional mount options (umask, etc.) # ----------------------------------------------------------------------------- # Storage devices for Syncthing service 1 @@ -37,6 +40,9 @@ readonly STORAGE_1A_LVM_GROUP="vg-srv" # LVM group name (used if LVM volume is readonly STORAGE_1A_UUID="none" # Set to device UUID to enable (find with: sudo blkid) readonly STORAGE_1A_KEY_FILE="none" # Set to key file path for automated unlock readonly STORAGE_1A_ENCRYPTION_TYPE="luks" # Options: "luks" or "bitlocker" +readonly STORAGE_1A_OWNER_USER="none" # Set to username for mount ownership (e.g., "sync_srv") +readonly STORAGE_1A_OWNER_GROUP="none" # Set to group name for mount ownership (e.g., "sync_srv") +readonly STORAGE_1A_MOUNT_OPTIONS="defaults" # Additional mount options (umask, etc.) readonly STORAGE_1B_MOUNT="storage1b" # Mount point under /mnt/ readonly STORAGE_1B_MAPPER="storage1b-data" # Device mapper name @@ -45,6 +51,9 @@ readonly STORAGE_1B_LVM_GROUP="vg-srv" # LVM group name (used if LVM volume is readonly STORAGE_1B_UUID="none" # Set to device UUID to enable (find with: sudo blkid) readonly STORAGE_1B_KEY_FILE="none" # Set to key file path for automated unlock readonly STORAGE_1B_ENCRYPTION_TYPE="luks" # Options: "luks" or "bitlocker" +readonly STORAGE_1B_OWNER_USER="none" # Set to username for mount ownership (e.g., "sync_srv") +readonly STORAGE_1B_OWNER_GROUP="none" # Set to group name for mount ownership (e.g., "sync_srv") +readonly STORAGE_1B_MOUNT_OPTIONS="defaults" # Additional mount options (umask, etc.) # ----------------------------------------------------------------------------- # Storage devices for Syncthing service 2 @@ -57,6 +66,9 @@ readonly STORAGE_2A_LVM_GROUP="vg-srv" # LVM group name (used if LVM volume is readonly STORAGE_2A_UUID="none" # Set to device UUID to enable (find with: sudo blkid) readonly STORAGE_2A_KEY_FILE="none" # Set to key file path for automated unlock readonly STORAGE_2A_ENCRYPTION_TYPE="luks" # Options: "luks" or "bitlocker" +readonly STORAGE_2A_OWNER_USER="none" # Set to username for mount ownership (e.g., "sync_srv") +readonly STORAGE_2A_OWNER_GROUP="none" # Set to group name for mount ownership (e.g., "sync_srv") +readonly STORAGE_2A_MOUNT_OPTIONS="defaults" # Additional mount options (umask, etc.) readonly STORAGE_2B_MOUNT="storage2b" # Mount point under /mnt/ readonly STORAGE_2B_MAPPER="storage2b-data" # Device mapper name @@ -65,6 +77,9 @@ readonly STORAGE_2B_LVM_GROUP="vg-srv" # LVM group name (used if LVM volume is readonly STORAGE_2B_UUID="none" # Set to device UUID to enable (find with: sudo blkid) readonly STORAGE_2B_KEY_FILE="none" # Set to key file path for automated unlock readonly STORAGE_2B_ENCRYPTION_TYPE="luks" # Options: "luks" or "bitlocker" +readonly STORAGE_2B_OWNER_USER="none" # Set to username for mount ownership (e.g., "sync_srv") +readonly STORAGE_2B_OWNER_GROUP="none" # Set to group name for mount ownership (e.g., "sync_srv") +readonly STORAGE_2B_MOUNT_OPTIONS="defaults" # Additional mount options (umask, etc.) # ----------------------------------------------------------------------------- # Network share configuration @@ -74,4 +89,6 @@ readonly NETWORK_SHARE_ADDRESS="none" # Set to share path to enable (e.g., "//s readonly NETWORK_SHARE_MOUNT="none" # Set to mount name (e.g., "network") readonly NETWORK_SHARE_PROTOCOL="none" # Set to protocol: "cifs", "nfs", etc. readonly NETWORK_SHARE_CREDENTIALS="none" # Set to credentials file path -readonly NETWORK_SHARE_OPTIONS="uid=1000,gid=1000,iocharset=utf8" # Mount options +readonly NETWORK_SHARE_OWNER_USER="none" # Set to username for mount ownership (e.g., "sync_srv") +readonly NETWORK_SHARE_OWNER_GROUP="none" # Set to group name for mount ownership (e.g., "sync_srv") +readonly NETWORK_SHARE_OPTIONS="iocharset=utf8" # Additional mount options (vers=3.0, etc.) diff --git a/lib/os-utils.sh b/lib/os-utils.sh new file mode 100644 index 0000000..2a6db84 --- /dev/null +++ b/lib/os-utils.sh @@ -0,0 +1,173 @@ +#!/usr/bin/env bash +# +# os-utils.sh - Operating System Utilities +# +# DESCRIPTION: +# Provides OS-level utility functions for user/group resolution and +# systemd service management. +# +# FUNCTIONS: +# - get_uid_from_username() - Resolve username to UID +# - get_gid_from_groupname() - Resolve group name to GID +# - build_mount_options() - Build mount options from usernames + additional options +# - start_service() - Start a systemd service +# - stop_service() - Stop a systemd service +# +# DEPENDENCIES: +# - id command (for UID lookup) +# - getent command (for GID lookup) +# - systemctl (for service management) +# +# NOTES: +# - Functions return SUCCESS/FAILURE status codes +# - Functions expect SUCCESS and FAILURE constants to be defined +# + +# ----------------------------------------------------------------------------- +# User and Group Resolution +# ----------------------------------------------------------------------------- + +function get_uid_from_username() { + local l_username=$1 + + if [ "$l_username" == "none" ]; then + echo "" + return "$SUCCESS" + fi + + local l_uid + l_uid=$(id -u "$l_username" 2>/dev/null) + + if [ -z "$l_uid" ]; then + echo "ERROR: User \"$l_username\" not found" + return "$FAILURE" + fi + + echo "$l_uid" + return "$SUCCESS" +} + +function get_gid_from_groupname() { + local l_groupname=$1 + + if [ "$l_groupname" == "none" ]; then + echo "" + return "$SUCCESS" + fi + + local l_gid + l_gid=$(getent group "$l_groupname" | cut -d: -f3) + + if [ -z "$l_gid" ]; then + echo "ERROR: Group \"$l_groupname\" not found" + return "$FAILURE" + fi + + echo "$l_gid" + return "$SUCCESS" +} + +function build_mount_options() { + local l_owner_user=$1 + local l_owner_group=$2 + local l_additional_options=$3 + + local l_uid + local l_gid + local l_final_options="" + + # Get UID from username + if [ "$l_owner_user" != "none" ]; then + l_uid=$(get_uid_from_username "$l_owner_user") + if [ $? -ne "$SUCCESS" ]; then + echo "$l_uid" # Print error message + return "$FAILURE" + fi + l_final_options="uid=$l_uid" + fi + + # Get GID from groupname + if [ "$l_owner_group" != "none" ]; then + l_gid=$(get_gid_from_groupname "$l_owner_group") + if [ $? -ne "$SUCCESS" ]; then + echo "$l_gid" # Print error message + return "$FAILURE" + fi + + if [ -n "$l_final_options" ]; then + l_final_options="$l_final_options,gid=$l_gid" + else + l_final_options="gid=$l_gid" + fi + fi + + # Add additional options + # Only add 'defaults' if no other options are present. Never combine 'defaults' with explicit options. + if [ "$l_additional_options" != "none" ] && [ "$l_additional_options" != "defaults" ]; then + if [ -n "$l_final_options" ]; then + l_final_options="$l_final_options,$l_additional_options" + else + l_final_options="$l_additional_options" + fi + fi + + # If no options specified, use defaults + if [ -z "$l_final_options" ]; then + l_final_options="defaults" + fi + + echo "$l_final_options" + return "$SUCCESS" +} + +# ----------------------------------------------------------------------------- +# Service Management +# ----------------------------------------------------------------------------- + +function stop_service() { + local l_service=$1 + + + if [ "$l_service" == "none" ]; then + return "$SUCCESS" + fi + if [ -z "$l_service" ]; then + echo "ERROR: stop_service called with empty service name" >&2 + return "$FAILURE" + fi + + echo "Stopping \"$l_service\" service..." + if systemctl is-active --quiet "$l_service"; then + if ! systemctl stop "$l_service"; then + echo "WARNING: Failed to stop service \"$l_service\"" + return "$FAILURE" + fi + echo -e "Done\n" + else + echo -e "Service \"$l_service\" inactive. Skipping.\n" + fi +} + +function start_service() { + local l_service=$1 + + + if [ "$l_service" == "none" ]; then + return "$SUCCESS" + fi + if [ -z "$l_service" ]; then + echo "ERROR: start_service called with empty service name" >&2 + return "$FAILURE" + fi + + echo "Starting \"$l_service\" service..." + if systemctl is-active --quiet "$l_service"; then + echo -e "Service \"$l_service\" active. Skipping.\\n" + else + if ! systemctl start "$l_service"; then + echo "ERROR: Failed to start service \"$l_service\"" + return "$FAILURE" + fi + echo -e "Done\n" + fi +} diff --git a/lib/storage.sh b/lib/storage.sh new file mode 100644 index 0000000..75e9ba3 --- /dev/null +++ b/lib/storage.sh @@ -0,0 +1,305 @@ +#!/usr/bin/env bash +# +# storage.sh - Storage Operations Library +# +# DESCRIPTION: +# Provides low-level primitives for storage device management including +# encryption (LUKS/BitLocker), LVM, mounting, and network shares. +# +# FUNCTIONS: +# Device waiting: +# - wait_for_device() - Wait for device to appear by UUID +# +# LVM management: +# - verify_lvm() - Verify LVM logical volume exists +# - lvm_is_active() - Check if LVM volume is active +# - activate_lvm() - Activate LVM logical volume +# - deactivate_lvm() - Deactivate LVM logical volume +# +# Encryption: +# - unlock_device() - Unlock LUKS/BitLocker device +# - lock_device() - Lock encrypted device +# +# Mounting: +# - mount_device() - Mount a device mapper to mount point +# - unmount_device() - Unmount a mount point +# - mount_network_path() - Mount network share (CIFS/NFS) +# +# DEPENDENCIES: +# - cryptsetup 2.4.0+ (for LUKS and BitLocker support) +# - lvm2 (for LVM operations) +# - mount/umount commands +# - build_mount_options() from os-utils.sh (for mount_network_path) +# +# NOTES: +# - Functions return SUCCESS/FAILURE status codes +# - Functions expect SUCCESS and FAILURE constants to be defined +# - All mount operations use /mnt/ as the base directory +# + +# ----------------------------------------------------------------------------- +# Device Waiting +# ----------------------------------------------------------------------------- + +function wait_for_device() { + local l_device_uuid="$1" + + for i in {1..5}; do + if [ -e "/dev/disk/by-uuid/$l_device_uuid" ]; then + return "$SUCCESS" + else + echo "Waiting for device $l_device_uuid... ${i}s" + sleep 1 + fi + done + + echo "ERROR: Device \"$l_device_uuid\" is not available." + return "$FAILURE" +} + +# ----------------------------------------------------------------------------- +# LVM Management +# ----------------------------------------------------------------------------- + +function verify_lvm() { + local l_lvm_name=$1 + local l_lvm_group=$2 + + if lvdisplay "$l_lvm_group/$l_lvm_name" >/dev/null 2>&1; then + return "$SUCCESS" + fi + + echo "ERROR: Logical volume \"$l_lvm_name\" is not available." + return "$FAILURE" +} + +function lvm_is_active() { + local l_lvm_name=$1 + local l_lvm_group=$2 + + # Use lvs to check if volume is active (more reliable than parsing lvdisplay) + if lvs --noheadings -o lv_active "$l_lvm_group/$l_lvm_name" 2>/dev/null | grep -q "active"; then + return "$SUCCESS" + else + return "$FAILURE" + fi +} + +function activate_lvm() { + local l_lvm_name=$1 + local l_lvm_group=$2 + + if [ "$l_lvm_name" == "none" ] || [ "$l_lvm_group" == "none" ]; then + return "$SUCCESS" + fi + + verify_lvm "$l_lvm_name" "$l_lvm_group" + if lvm_is_active "$l_lvm_name" "$l_lvm_group"; then + echo -e "Logical volume \"$l_lvm_name\" already activated. Skipping.\n" + else + echo "Activating $l_lvm_name..." + if ! lvchange -ay "$l_lvm_group/$l_lvm_name"; then + echo "ERROR: Failed to activate LVM logical volume \"$l_lvm_group/$l_lvm_name\"" + return "$FAILURE" + fi + echo -e "Done\n" + fi +} + +function deactivate_lvm() { + local l_lvm_name=$1 + local l_lvm_group=$2 + + if [ "$l_lvm_name" == "none" ] || [ "$l_lvm_group" == "none" ]; then + return "$SUCCESS" + fi + + # Skip if LVM doesn't exist (may have been removed) + if ! lvdisplay "$l_lvm_group/$l_lvm_name" >/dev/null 2>&1; then + return "$SUCCESS" + fi + + if lvm_is_active "$l_lvm_name" "$l_lvm_group"; then + echo "Deactivating $l_lvm_name..." + if ! lvchange -an "$l_lvm_group/$l_lvm_name"; then + echo "WARNING: Failed to deactivate LVM logical volume \"$l_lvm_group/$l_lvm_name\"" + return "$FAILURE" + fi + echo -e "Done\n" + else + echo -e "Logical volume \"$l_lvm_name\" already deactivated. Skipping.\n" + fi +} + +# ----------------------------------------------------------------------------- +# Encryption Operations +# ----------------------------------------------------------------------------- + +function unlock_device() { + local l_device_uuid=$1 + local l_mapper=$2 + local l_key_file=$3 + local l_encryption_type=${4:-luks} + + if [ "$l_device_uuid" == "none" ] || [ "$l_mapper" == "none" ]; then + echo -e "Device not configured (device_uuid=\"$l_device_uuid\"; mapper=\"$l_mapper\"). Skipping.\n" + return "$SUCCESS" + fi + + # Check if already unlocked + if cryptsetup status "$l_mapper" >/dev/null 2>&1; then + echo -e "Partition \"$l_mapper\" unlocked. Skipping.\n" + return "$SUCCESS" + fi + + echo "Unlocking $l_mapper ($l_encryption_type)..." + wait_for_device "$l_device_uuid" + + # Determine the device path - prefer by-uuid for consistency + local l_device_path="/dev/disk/by-uuid/$l_device_uuid" + + if [ "$l_encryption_type" == "bitlocker" ]; then + # BitLocker support using native cryptsetup (v2.4.0+) + if [ "$l_key_file" != "none" ] && [ -f "$l_key_file" ]; then + if ! cryptsetup open --type bitlk "$l_device_path" "$l_mapper" --key-file="$l_key_file"; then + echo "ERROR: Failed to unlock BitLocker device \"$l_device_uuid\" as \"$l_mapper\" using key file" + return "$FAILURE" + fi + else + if ! cryptsetup open --type bitlk "$l_device_path" "$l_mapper"; then + echo "ERROR: Failed to unlock BitLocker device \"$l_device_uuid\" as \"$l_mapper\" with interactive password" + return "$FAILURE" + fi + fi + elif [ "$l_encryption_type" == "luks" ]; then + # LUKS support + if [ "$l_key_file" != "none" ] && [ -f "$l_key_file" ]; then + if ! cryptsetup open --type luks "$l_device_path" "$l_mapper" --key-file="$l_key_file"; then + echo "ERROR: Failed to unlock LUKS device \"$l_device_uuid\" as \"$l_mapper\" using key file" + return "$FAILURE" + fi + else + if ! cryptsetup open --type luks "$l_device_path" "$l_mapper"; then + echo "ERROR: Failed to unlock LUKS device \"$l_device_uuid\" as \"$l_mapper\" with interactive password" + return "$FAILURE" + fi + fi + else + echo "ERROR: Unsupported encryption type \"$l_encryption_type\" for device \"$l_mapper\"" + return "$FAILURE" + fi + + echo -e "Done\n" +} + +function lock_device() { + local l_mapper=$1 + local l_encryption_type=${2:-luks} + + if [ "$l_mapper" == "none" ]; then + return "$SUCCESS" + fi + + if cryptsetup status "$l_mapper" >/dev/null 2>&1; then + echo "Locking $l_mapper ($l_encryption_type)..." + if ! cryptsetup close "$l_mapper"; then + echo "WARNING: Failed to lock device \"$l_mapper\"" + return "$FAILURE" + fi + echo -e "Done\n" + else + echo -e "Partition \"$l_mapper\" locked. Skipping.\n" + fi +} + +# ----------------------------------------------------------------------------- +# Mount Operations +# ----------------------------------------------------------------------------- + +function mount_device() { + local l_mapper=$1 + local l_mount=$2 + local l_mount_options=${3:-defaults} + + if [ "$l_mapper" == "none" ] || [ "$l_mount" == "none" ]; then + echo -e "Mount not configured (mapper=\"$l_mapper\"; mount_point=\"$l_mount\"). Skipping.\n" + return "$SUCCESS" + elif mountpoint -q "/mnt/$l_mount"; then + echo -e "Mountpoint \"$l_mount\" mounted. Skipping.\n" + else + # Check if mapper device exists before attempting mount + if [ ! -e "/dev/mapper/$l_mapper" ]; then + echo -e "Mapper device \"/dev/mapper/$l_mapper\" does not exist. Skipping mount.\n" + return "$SUCCESS" + fi + + echo "Mounting $l_mount..." + mkdir -p "/mnt/$l_mount" + + if ! mount -o "$l_mount_options" "/dev/mapper/$l_mapper" "/mnt/$l_mount"; then + echo "ERROR: Failed to mount \"/dev/mapper/$l_mapper\" to \"/mnt/$l_mount\"" + return "$FAILURE" + fi + echo -e "Done\n" + fi +} + +function unmount_device() { + local l_mount=$1 + + if [ "$l_mount" == "none" ]; then + return "$SUCCESS" + fi + + if mountpoint -q "/mnt/$l_mount"; then + echo "Unmounting $l_mount..." + if ! umount "/mnt/$l_mount"; then + echo "WARNING: Failed to unmount \"/mnt/$l_mount\"" + return "$FAILURE" + else + echo -e "Done\n" + fi + else + echo -e "Mountpoint \"$l_mount\" unmounted. Skipping.\n" + fi +} + +function mount_network_path() { + local l_network_path=$1 + local l_mount_path=$2 + local l_protocol=$3 + local l_credentials=$4 + local l_owner_user=$5 + local l_owner_group=$6 + local l_additional_options=$7 + + if [ "$l_protocol" == "none" ]; then + return "$SUCCESS" + fi + + if mountpoint -q "/mnt/$l_mount_path"; then + echo -e "Mountpoint \"$l_mount_path\" mounted. Skipping.\n" + else + echo "Mounting $l_mount_path..." + mkdir -p "/mnt/$l_mount_path" + + # Build mount options from username/groupname + local l_mount_options + l_mount_options=$(build_mount_options "$l_owner_user" "$l_owner_group" "$l_additional_options") + if [ $? -ne "$SUCCESS" ]; then + echo "$l_mount_options" # Print error message + return "$FAILURE" + fi + + # Prepend credentials if provided + if [ "$l_credentials" != "none" ]; then + l_mount_options="credentials=$l_credentials,$l_mount_options" + fi + + if ! mount -t "$l_protocol" -o "$l_mount_options" "$l_network_path" "/mnt/$l_mount_path"; then + echo "ERROR: Failed to mount network path \"$l_network_path\" to \"/mnt/$l_mount_path\"" + return "$FAILURE" + fi + echo -e "Done\n" + fi +} diff --git a/srv-ctl.sh b/srv-ctl.sh index 6cbffa8..16e2437 100755 --- a/srv-ctl.sh +++ b/srv-ctl.sh @@ -19,7 +19,8 @@ # ./srv-ctl.sh stop # Stop services and unmount devices # ./srv-ctl.sh unlock-only # Only unlock and mount devices # ./srv-ctl.sh stop-services-only # Only stop services -# ./srv-ctl.sh validate-config # Validate configuration +# ./srv-ctl.sh validate-config # Validate configuration without making changes +# ./srv-ctl.sh help # Show help message # # CONFIGURATION: # Copy config.local.template to config.local and customize settings. @@ -36,6 +37,24 @@ set -eou pipefail readonly SUCCESS=0 readonly FAILURE=1 +# ----------------------------------------------------------------------------- +# Source library files +# ----------------------------------------------------------------------------- + +# Get the directory where this script resides +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly SCRIPT_DIR + +# Source library functions +# shellcheck disable=SC1091 # Library files exist +source "${SCRIPT_DIR}/lib/os-utils.sh" +source "${SCRIPT_DIR}/lib/storage.sh" + + +# ----------------------------------------------------------------------------- +# Usage +# ----------------------------------------------------------------------------- + function show_usage() { echo "Usage: $0 < start | stop | unlock-only | stop-services-only | validate-config | help | -h >" echo "" @@ -48,220 +67,10 @@ function show_usage() { echo " help, -h Show this help message" } -function wait_for_device() { - local l_device_uuid="$1" - - for i in {1..5}; do - if [ -e "/dev/disk/by-uuid/$l_device_uuid" ]; then - return $SUCCESS - else - echo "Waiting for device $l_device_uuid... ${i}s" - sleep 1 - fi - done - - echo "ERROR: Device \"$l_device_uuid\" is not available." - return $FAILURE -} - -function verify_lvm() { - local l_lvm_name=$1 - local l_lvm_group=$2 - - if lvdisplay "$l_lvm_group/$l_lvm_name" >/dev/null; then - return $SUCCESS - fi - - echo "ERROR: Logic volume \"$l_lvm_name\" is not available." - return $FAILURE -} - -function lvm_is_active() { - local l_lvm_name=$1 - local l_lvm_group=$2 - - # Use lvs to check if volume is active (more reliable than parsing lvdisplay) - if lvs --noheadings -o lv_active "$l_lvm_group/$l_lvm_name" 2>/dev/null | grep -q "active"; then - return $SUCCESS - else - return $FAILURE - fi -} - -function activate_lvm() { - local l_lvm_name=$1 - local l_lvm_group=$2 - - if [ "$l_lvm_name" == "none" ] || [ "$l_lvm_group" == "none" ]; then - return $SUCCESS - fi - - verify_lvm "$l_lvm_name" "$l_lvm_group" - if lvm_is_active "$l_lvm_name" "$l_lvm_group"; then - echo -e "Logic volume \"$l_lvm_name\" already activated. Skipping.\n" - else - echo "Activating $l_lvm_name..." - if ! lvchange -ay "$l_lvm_group/$l_lvm_name"; then - echo "ERROR: Failed to activate LVM logical volume \"$l_lvm_group/$l_lvm_name\"" - return $FAILURE - fi - echo -e "Done\n" - fi -} - -function deactivate_lvm() { - local l_lvm_name=$1 - local l_lvm_group=$2 - - if [ "$l_lvm_name" == "none" ] || [ "$l_lvm_group" == "none" ]; then - return $SUCCESS - fi - - verify_lvm "$l_lvm_name" "$l_lvm_group" - - if lvm_is_active "$l_lvm_name" "$l_lvm_group"; then - echo "Deactivating $l_lvm_name..." - if ! lvchange -an "$l_lvm_group/$l_lvm_name"; then - echo "WARNING: Failed to deactivate LVM logical volume \"$l_lvm_group/$l_lvm_name\"" - return $FAILURE - fi - echo -e "Done\n" - else - echo -e "Logic volume \"$l_lvm_name\" already deactivated. Skipping.\n" - fi -} - -function unlock_device() { - local l_device_uuid=$1 - local l_mapper=$2 - local l_key_file=$3 - local l_encryption_type=${4:-luks} - - if [ "$l_device_uuid" == "none" ] || [ "$l_mapper" == "none" ]; then - echo -e "Device not configured (device_uuid=\"$l_device_uuid\"; mapper=\"$l_mapper\"). Skipping.\n" - return $SUCCESS - fi - - # Check if already unlocked - if cryptsetup status "$l_mapper" >/dev/null; then - echo -e "Partition \"$l_mapper\" unlocked. Skipping.\n" - return $SUCCESS - fi - - echo "Unlocking $l_mapper ($l_encryption_type)..." - wait_for_device "$l_device_uuid" - - # Determine the device path - prefer by-uuid for consistency - local l_device_path="/dev/disk/by-uuid/$l_device_uuid" - - if [ "$l_encryption_type" == "bitlocker" ]; then - # BitLocker support using native cryptsetup (v2.4.0+) - if [ "$l_key_file" != "none" ] && [ -f "$l_key_file" ]; then - if ! cryptsetup open --type bitlk "$l_device_path" "$l_mapper" --key-file="$l_key_file"; then - echo "ERROR: Failed to unlock BitLocker device \"$l_device_uuid\" as \"$l_mapper\" using key file" - return $FAILURE - fi - else - if ! cryptsetup open --type bitlk "$l_device_path" "$l_mapper"; then - echo "ERROR: Failed to unlock BitLocker device \"$l_device_uuid\" as \"$l_mapper\" with interactive password" - return $FAILURE - fi - fi - elif [ "$l_encryption_type" == "luks" ]; then - # LUKS support - if [ "$l_key_file" != "none" ] && [ -f "$l_key_file" ]; then - if ! cryptsetup open --type luks "$l_device_path" "$l_mapper" --key-file="$l_key_file"; then - echo "ERROR: Failed to unlock LUKS device \"$l_device_uuid\" as \"$l_mapper\" using key file" - return $FAILURE - fi - else - if ! cryptsetup open --type luks "$l_device_path" "$l_mapper"; then - echo "ERROR: Failed to unlock LUKS device \"$l_device_uuid\" as \"$l_mapper\" with interactive password" - return $FAILURE - fi - fi - else - echo "ERROR: Unsupported encryption type \"$l_encryption_type\" for device \"$l_mapper\"" - return $FAILURE - fi - - echo -e "Done\n" -} - -function lock_device() { - local l_mapper=$1 - local l_encryption_type=${2:-luks} - - if cryptsetup status "$l_mapper" >/dev/null; then - echo "Locking $l_mapper ($l_encryption_type)..." - if ! cryptsetup close "$l_mapper"; then - echo "WARNING: Failed to lock device \"$l_mapper\"" - return $FAILURE - fi - echo -e "Done\n" - else - echo -e "Partition \"$l_mapper\" locked. Skipping.\n" - fi -} - -function mount_network_path() { - local l_network_path=$1 - local l_mount_path=$2 - local l_protocol=$3 - local l_credentials=$4 - local l_options=$5 - if [ "$l_protocol" == "none" ]; then - return $SUCCESS - fi - - if mountpoint -q "/mnt/$l_mount_path"; then - echo -e "Mountpoint \"$l_mount_path\" mounted. Skipping.\n" - else - echo "Mounting $l_mount_path..." - mkdir -p "/mnt/$l_mount_path" - if ! mount -t "$l_protocol" -o "credentials=$l_credentials,$l_options" "$l_network_path" "/mnt/$l_mount_path"; then - echo "ERROR: Failed to mount network path \"$l_network_path\" to \"/mnt/$l_mount_path\"" - return $FAILURE - fi - echo -e "Done\n" - fi -} - -function mount_device() { - local l_mapper=$1 - local l_mount=$2 - - if [ "$l_mapper" == "none" ] || [ "$l_mount" == "none" ]; then - echo -e "Mount not configured (mapper=\"$l_mapper\"; mount_point=\"$l_mount\"). Skipping.\n" - elif mountpoint -q "/mnt/$l_mount"; then - echo -e "Mountpoint \"$l_mount\" mounted. Skipping.\n" - else - echo "Mounting $l_mount..." - mkdir -p "/mnt/$l_mount" - if ! mount "/dev/mapper/$l_mapper" "/mnt/$l_mount"; then - echo "ERROR: Failed to mount \"/dev/mapper/$l_mapper\" to \"/mnt/$l_mount\"" - return $FAILURE - fi - echo -e "Done\n" - fi -} - -function unmount_device() { - local l_mount=$1 - - if mountpoint -q "/mnt/$l_mount"; then - echo "Unmounting $l_mount..." - if ! umount "/mnt/$l_mount"; then - echo "WARNING: Failed to unmount \"/mnt/$l_mount\"" - return $FAILURE - else - echo -e "Done\n" - fi - else - echo -e "Mountpoint \"$l_mount\" unmounted. Skipping.\n" - fi -} +# ----------------------------------------------------------------------------- +# Device Orchestration (combines library primitives) +# ----------------------------------------------------------------------------- function open_device() { local l_mount=$1 @@ -271,15 +80,39 @@ function open_device() { local l_uuid=$5 local l_key_file=$6 local l_encryption_type=${7:-luks} + local l_owner_user=${8:-none} + local l_owner_group=${9:-none} + local l_additional_options=${10:-defaults} - # Step 1: Activate LVM - activate_lvm "$l_lvm_name" "$l_lvm_group" || return $FAILURE + # Check if device is configured - UUID is the primary enable/disable flag + if [ "$l_uuid" == "none" ]; then + echo -e "Device not configured (uuid=\"none\"). Skipping.\n" + return "$SUCCESS" + fi + + # Validate that mapper and mount are also configured + if [ "$l_mapper" == "none" ] || [ "$l_mount" == "none" ]; then + echo "ERROR: Device UUID is set but mapper or mount point is 'none'" + echo " UUID: $l_uuid, MAPPER: $l_mapper, MOUNT: $l_mount" + return "$FAILURE" + fi + + # Build mount options from username/groupname + local l_mount_options + l_mount_options=$(build_mount_options "$l_owner_user" "$l_owner_group" "$l_additional_options") + if [ $? -ne $SUCCESS ]; then + echo "$l_mount_options" # Print error message + return "$FAILURE" + fi + + # Step 1: Activate LVM (if configured) + activate_lvm "$l_lvm_name" "$l_lvm_group" || return "$FAILURE" # Step 2: Unlock encrypted device - unlock_device "$l_uuid" "$l_mapper" "$l_key_file" "$l_encryption_type" || return $FAILURE + unlock_device "$l_uuid" "$l_mapper" "$l_key_file" "$l_encryption_type" || return "$FAILURE" # Step 3: Mount device - mount_device "$l_mapper" "$l_mount" || return $FAILURE + mount_device "$l_mapper" "$l_mount" "$l_mount_options" || return "$FAILURE" } function close_device() { @@ -289,77 +122,53 @@ function close_device() { local l_lvm_group=$4 local l_encryption_type=${5:-luks} + # Check if any component is configured before attempting cleanup + # Use mapper as the check since it's required for both lock and unmount + if [ "$l_mapper" == "none" ] && [ "$l_mount" == "none" ]; then + return "$SUCCESS" + fi + # Continue cleanup even if individual steps fail unmount_device "$l_mount" || true lock_device "$l_mapper" "$l_encryption_type" || true deactivate_lvm "$l_lvm_name" "$l_lvm_group" || true } -function stop_service() { - local l_service=$1 - - if [ "$l_service" == "none" ]; then - return $SUCCESS - fi - - echo "Stopping \"$l_service\" service..." - if systemctl is-active --quiet "$l_service"; then - if ! systemctl stop "$l_service"; then - echo "WARNING: Failed to stop service \"$l_service\"" - return $FAILURE - fi - echo -e "Done\n" - else - echo -e "Service \"$l_service\" inactive. Skipping.\n" - fi -} -function start_service() { - local l_service=$1 - - if [ "$l_service" == "none" ]; then - return $SUCCESS - fi - - echo "Starting \"$l_service\" service..." - if systemctl is-active --quiet "$l_service"; then - echo -e "Service \"$l_service\" active. Skipping.\n" - else - if ! systemctl start "$l_service"; then - echo "ERROR: Failed to start service \"$l_service\"" - return $FAILURE - fi - echo -e "Done\n" - fi -} function open_all_devices() { # open primary data device open_device "$PRIMARY_DATA_MOUNT" "$PRIMARY_DATA_MAPPER" \ "$PRIMARY_DATA_LVM_NAME" "$PRIMARY_DATA_LVM_GROUP" \ - "$PRIMARY_DATA_UUID" "$PRIMARY_DATA_KEY_FILE" "$PRIMARY_DATA_ENCRYPTION_TYPE" + "$PRIMARY_DATA_UUID" "$PRIMARY_DATA_KEY_FILE" "$PRIMARY_DATA_ENCRYPTION_TYPE" \ + "$PRIMARY_DATA_OWNER_USER" "$PRIMARY_DATA_OWNER_GROUP" "$PRIMARY_DATA_MOUNT_OPTIONS" # open storage devices for service 1 open_device "$STORAGE_1A_MOUNT" "$STORAGE_1A_MAPPER" \ "$STORAGE_1A_LVM_NAME" "$STORAGE_1A_LVM_GROUP" \ - "$STORAGE_1A_UUID" "$STORAGE_1A_KEY_FILE" "$STORAGE_1A_ENCRYPTION_TYPE" + "$STORAGE_1A_UUID" "$STORAGE_1A_KEY_FILE" "$STORAGE_1A_ENCRYPTION_TYPE" \ + "$STORAGE_1A_OWNER_USER" "$STORAGE_1A_OWNER_GROUP" "$STORAGE_1A_MOUNT_OPTIONS" open_device "$STORAGE_1B_MOUNT" "$STORAGE_1B_MAPPER" \ "$STORAGE_1B_LVM_NAME" "$STORAGE_1B_LVM_GROUP" \ - "$STORAGE_1B_UUID" "$STORAGE_1B_KEY_FILE" "$STORAGE_1B_ENCRYPTION_TYPE" + "$STORAGE_1B_UUID" "$STORAGE_1B_KEY_FILE" "$STORAGE_1B_ENCRYPTION_TYPE" \ + "$STORAGE_1B_OWNER_USER" "$STORAGE_1B_OWNER_GROUP" "$STORAGE_1B_MOUNT_OPTIONS" # open storage devices for service 2 open_device "$STORAGE_2A_MOUNT" "$STORAGE_2A_MAPPER" \ "$STORAGE_2A_LVM_NAME" "$STORAGE_2A_LVM_GROUP" \ - "$STORAGE_2A_UUID" "$STORAGE_2A_KEY_FILE" "$STORAGE_2A_ENCRYPTION_TYPE" + "$STORAGE_2A_UUID" "$STORAGE_2A_KEY_FILE" "$STORAGE_2A_ENCRYPTION_TYPE" \ + "$STORAGE_2A_OWNER_USER" "$STORAGE_2A_OWNER_GROUP" "$STORAGE_2A_MOUNT_OPTIONS" open_device "$STORAGE_2B_MOUNT" "$STORAGE_2B_MAPPER" \ "$STORAGE_2B_LVM_NAME" "$STORAGE_2B_LVM_GROUP" \ - "$STORAGE_2B_UUID" "$STORAGE_2B_KEY_FILE" "$STORAGE_2B_ENCRYPTION_TYPE" + "$STORAGE_2B_UUID" "$STORAGE_2B_KEY_FILE" "$STORAGE_2B_ENCRYPTION_TYPE" \ + "$STORAGE_2B_OWNER_USER" "$STORAGE_2B_OWNER_GROUP" "$STORAGE_2B_MOUNT_OPTIONS" # open network storage mount_network_path "$NETWORK_SHARE_ADDRESS" "$NETWORK_SHARE_MOUNT" "$NETWORK_SHARE_PROTOCOL" \ - "$NETWORK_SHARE_CREDENTIALS" "$NETWORK_SHARE_OPTIONS" + "$NETWORK_SHARE_CREDENTIALS" "$NETWORK_SHARE_OWNER_USER" "$NETWORK_SHARE_OWNER_GROUP" \ + "$NETWORK_SHARE_OPTIONS" } function close_all_devices() { @@ -385,22 +194,26 @@ function close_all_devices() { unmount_device "$NETWORK_SHARE_MOUNT" } +# ----------------------------------------------------------------------------- +# Service Orchestration +# ----------------------------------------------------------------------------- + function start_all_services() { if [ "$ST_SERVICE_1" != "none" ] || [ "$ST_SERVICE_2" != "none" ] || [ "$DOCKER_SERVICE" != "none" ]; then echo "Reloading systemd units..." if ! systemctl daemon-reload; then echo "ERROR: Failed to reload systemd units" - return $FAILURE + return "$FAILURE" fi echo -e "Done\n" else echo -e "No services managed. Skipping.\n" - return $SUCCESS + return "$SUCCESS" fi - start_service "$ST_SERVICE_1" - start_service "$ST_SERVICE_2" - start_service "$DOCKER_SERVICE" + start_service "$ST_SERVICE_1" || return "$FAILURE" + start_service "$ST_SERVICE_2" || return "$FAILURE" + start_service "$DOCKER_SERVICE" || return "$FAILURE" } function stop_all_services() { @@ -409,6 +222,10 @@ function stop_all_services() { stop_service "$DOCKER_SERVICE" } +# ----------------------------------------------------------------------------- +# High-level Workflows +# ----------------------------------------------------------------------------- + function system_on() { stop_all_services open_all_devices @@ -426,21 +243,20 @@ function system_off() { echo -e " System is OFF :)\n" } +# ----------------------------------------------------------------------------- +# Configuration and Requirements +# ----------------------------------------------------------------------------- + function init_globals() { local l_config_file_name=$1 - local l_script_dir - l_script_dir="$( - cd "$(dirname "${BASH_SOURCE[0]}")" - pwd - )" - local l_config_file="${l_script_dir}/${l_config_file_name}" + local l_config_file="${SCRIPT_DIR}/${l_config_file_name}" if [ -f "$l_config_file" ]; then - # shellcheck source=/dev/null + # shellcheck disable=SC1090 # Dynamic config file sourcing source "$l_config_file" else echo "ERROR: Configuration file \"$l_config_file_name\" is missing." - return $FAILURE + return "$FAILURE" fi } @@ -451,21 +267,21 @@ function validate_encryption_type() { if [ "$l_encryption_type" != "luks" ] && [ "$l_encryption_type" != "bitlocker" ]; then echo "ERROR: Unknown encryption type \"$l_encryption_type\" for device \"$l_device_name\"" echo " Supported types: luks, bitlocker" - return $FAILURE + return "$FAILURE" fi - return $SUCCESS + return "$SUCCESS" } function verify_requirements() { if [ "$EUID" -ne "0" ]; then echo "ERROR: Please run as root" - return $FAILURE + return "$FAILURE" fi if ! command -v cryptsetup &>/dev/null; then echo "ERROR: 'cryptsetup' utility is not available" - return $FAILURE + return "$FAILURE" fi # Check for LVM utilities if needed @@ -476,18 +292,18 @@ function verify_requirements() { [ "${PRIMARY_DATA_LVM_NAME:-none}" != "none" ]; then if ! command -v lvdisplay &>/dev/null; then echo "ERROR: 'lvm2' utility is not available" - return $FAILURE + return "$FAILURE" fi fi # Validate service configuration if [ "${ST_USER_1:-none}" != "none" ] && [ -z "${ST_SERVICE_1:-}" ]; then echo "ERROR: ST_USER_1 is set but ST_SERVICE_1 is empty" - return $FAILURE + return "$FAILURE" fi if [ "${ST_USER_2:-none}" != "none" ] && [ -z "${ST_SERVICE_2:-}" ]; then echo "ERROR: ST_USER_2 is set but ST_SERVICE_2 is empty" - return $FAILURE + return "$FAILURE" fi # Validate encryption types for all configured devices @@ -503,7 +319,7 @@ function verify_requirements() { IFS=':' read -r device_name encryption_type device_uuid <<< "$device_info" # Only validate encryption type for enabled devices if [ "$device_uuid" != "none" ]; then - validate_encryption_type "$encryption_type" "$device_name" || return $FAILURE + validate_encryption_type "$encryption_type" "$device_name" || return "$FAILURE" fi done @@ -517,26 +333,32 @@ function verify_requirements() { if [ "$l_version_check" != "$CRYPTSETUP_MIN_VERSION" ]; then echo "ERROR: cryptsetup version $CRYPTSETUP_MIN_VERSION or newer is required (current: $l_cryptsetup_version_current)" echo " Version $CRYPTSETUP_MIN_VERSION+ is needed for full LUKS and BitLocker support" - return $FAILURE + return "$FAILURE" fi } +# ----------------------------------------------------------------------------- +# Configuration Validation +# ----------------------------------------------------------------------------- + function validate_config() { echo "=== Configuration Validation ===" local errors=0 + # Get script directory first + local l_script_dir + l_script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + # Check config file exists - if [ ! -f "config.local" ]; then + if [ ! -f "${l_script_dir}/config.local" ]; then echo "❌ config.local not found (copy config.local.template and customize)" - return $FAILURE + return "$FAILURE" fi echo "✅ config.local found" # Load configuration - local l_script_dir - l_script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - # shellcheck source=/dev/null + # shellcheck disable=SC1090 # Dynamic config file sourcing source "${l_script_dir}/config.local" # Validate services @@ -581,10 +403,10 @@ function validate_config() { echo "" if [ "$errors" -eq 0 ]; then echo "🎉 Validation PASSED ($enabled devices enabled)" - return $SUCCESS + return "$SUCCESS" else echo "❌ Validation FAILED ($errors errors)" - return $FAILURE + return "$FAILURE" fi } @@ -594,13 +416,14 @@ function _validate_service() { if [ "$user" != "none" ]; then if [ -z "$service" ]; then echo "❌ $name: user '$user' set but service empty" - return $FAILURE + return "$FAILURE" else echo "✅ $name: $service (user: $user)" fi else echo "ℹ️ $name: disabled" fi + return "$SUCCESS" } function _validate_simple_service() { @@ -610,6 +433,7 @@ function _validate_simple_service() { else echo "ℹ️ $name: disabled" fi + return "$SUCCESS" } function _validate_device() { @@ -649,57 +473,66 @@ function _validate_device() { if [ "$has_errors" = true ]; then echo "❌ $label: $error_details" - return $FAILURE + return "$FAILURE" else echo "✅ $label: $encryption$key_status" - return $SUCCESS + return "$SUCCESS" fi } function _validate_network_share() { + local protocol="${NETWORK_SHARE_PROTOCOL:-none}" local address="${NETWORK_SHARE_ADDRESS:-none}" local credentials="${NETWORK_SHARE_CREDENTIALS:-none}" - if [ "$address" = "none" ]; then + if [ "$protocol" = "none" ]; then echo "ℹ️ disabled" - return $SUCCESS + return "$SUCCESS" fi if [ "$credentials" != "none" ] && [ ! -f "$credentials" ]; then echo "❌ enabled but credentials file not found: $credentials" - return $FAILURE + return "$FAILURE" fi - echo "✅ enabled ($address)" - return $SUCCESS + echo "✅ enabled ($protocol: $address)" + return "$SUCCESS" } +# ----------------------------------------------------------------------------- +# Main Entry Point +# ----------------------------------------------------------------------------- + function main() { if [ "$#" -ne 1 ]; then show_usage - exit $FAILURE + exit "$FAILURE" fi local l_action="$1" - # Handle commands that don't need root privileges + # Handle commands that don't need root privileges or requirements check case "$l_action" in validate-config) validate_config return $? ;; - help) + help|-h) show_usage - exit $SUCCESS + exit "$SUCCESS" + ;; + start|stop|unlock-only|stop-services-only) + # Valid commands that need root and requirements ;; - -h) + *) + # Invalid command show_usage - exit $SUCCESS + exit "$FAILURE" ;; esac init_globals "config.local" - verify_requirements "$@" + verify_requirements case "$l_action" in start) @@ -714,10 +547,6 @@ function main() { stop-services-only) stop_all_services ;; - *) - show_usage - exit $FAILURE - ;; esac } diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..950c11f --- /dev/null +++ b/tests/README.md @@ -0,0 +1,147 @@ +# Testing Guide + +## Quick Start + +```bash +# Local development tests (fast, no root required) +bats tests/unit/*.bats # Unit tests +shellcheck -x srv-ctl.sh lib/*.sh # Lint + +# Full VM tests (CI primary) - requires QEMU/KVM +./tests/vm/run-tests.sh # Integration tests +./tests/vm/run-e2e-tests.sh # E2E tests +``` + +## Test Architecture + +| Level | Environment | Root | Use Case | +|-----------------|-------------|------|-----------------------------------| +| **Unit** | Local | No | Fast function-level tests (bats) | +| **Lint** | Local | No | Static analysis (ShellCheck) | +| **E2E** | VM | Yes | CLI workflows with real devices | +| **Integration** | VM | Yes | Storage operations (LUKS/LVM) | + +### Unit Tests + +Fast, isolated tests using the bats framework. No root or special setup required. + +```bash +bats tests/unit/*.bats +``` + +### Lint + +Static analysis with ShellCheck to catch common shell scripting errors. + +```bash +shellcheck -x srv-ctl.sh lib/*.sh +``` + +### VM Tests (CI Primary) + +Full system validation in QEMU VMs with real systemd, devices, and multi-OS testing. + +```bash +# Integration tests (LUKS, LVM, mount operations) +./tests/vm/run-tests.sh ubuntu-22.04 + +# E2E tests (full workflows) +./tests/vm/run-e2e-tests.sh ubuntu-22.04 +``` + +Supported OS versions (cryptsetup >=2.4.0 required for BitLocker): + +- `ubuntu-22.04`, `ubuntu-24.04` +- `debian-12`, `debian-13` + +**Requirements**: `qemu-system-x86`, `qemu-utils`, `cloud-image-utils` + +## CI/CD Workflows + +Tests run automatically via GitHub Actions: + +| Workflow | Trigger | Description | +|----------------------------|------------------|---------------------------------| +| `lint.yml` | Push, PR | ShellCheck + syntax validation | +| `test-unit.yml` | Push, PR | Bats unit tests | +| `test-integration-vm.yml` | Push, PR | VM integration tests (4 OSes) | +| `test-e2e.yml` | Push, PR | VM E2E tests (4 OSes) | + +## Writing Tests + +### Unit Tests (bats) + +```bash +@test "function_name does something" { + run function_name arg1 arg2 + [ "$status" -eq 0 ] + [ "$output" = "expected output" ] +} +``` + +### Integration Tests + +```bash +test_operation() { + run_test "Operation description" + + if perform_operation; then + log_pass "Operation successful" + else + log_fail "Operation failed" + return 1 + fi +} +``` + +## Safety + +- ✅ **Unit/Lint**: Zero system impact, no root +- ✅ **VM tests**: Full isolation in QEMU VMs +- ❌ **Never run integration tests directly on host** (use VM) + +## Troubleshooting + +### Bats not found + +```bash +# Install bats +git clone --branch v1.13.0 --depth 1 https://github.com/bats-core/bats-core.git /tmp/bats +sudo /tmp/bats/install.sh /usr/local +``` + +### VM tests fail + +```bash +# Download OS image first +./tests/vm/download-image.sh ubuntu-22.04 + +# Verify QEMU is installed +which qemu-system-x86_64 +``` + +## Project Structure + +```text +tests/ +├── run-tests.sh # Test runner (for use inside VM) +├── unit/ # Unit tests (bats) +│ ├── test-os-utils.bats +│ └── test-storage.bats +├── e2e/ # End-to-end tests +│ └── test-e2e.sh +├── integration/ # Integration tests (VM only) +│ ├── test-luks.sh +│ ├── test-lvm.sh +│ └── test-mount.sh +├── fixtures/ # Test configs and setup helpers +│ ├── config.local.test +│ ├── setup-test-env.sh +│ └── cleanup-test-env.sh +└── vm/ # VM test infrastructure + ├── run-tests.sh # VM integration test runner + ├── run-e2e-tests.sh # VM E2E test runner + ├── download-image.sh # Cloud image downloader + ├── cleanup.sh # VM cleanup script + └── vm-common.sh # Shared VM functions +``` diff --git a/tests/e2e/test-e2e.sh b/tests/e2e/test-e2e.sh new file mode 100755 index 0000000..c133ff1 --- /dev/null +++ b/tests/e2e/test-e2e.sh @@ -0,0 +1,619 @@ +#!/bin/bash +# End-to-end tests for srv-ctl.sh +# Tests high-level workflows using the test configuration + +set -euo pipefail + +# Test counters +TESTS_RUN=0 +TESTS_PASSED=0 +TESTS_FAILED=0 + +# Colors +readonly RED='\033[0;31m' +readonly GREEN='\033[0;32m' +readonly YELLOW='\033[1;33m' +readonly NC='\033[0m' + +# Script directory +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +log_test() { + echo -e "${YELLOW}[TEST]${NC} $*" +} + +log_pass() { + echo -e "${GREEN}[PASS]${NC} $*" +} + +log_fail() { + echo -e "${RED}[FAIL]${NC} $*" +} + +run_test() { + TESTS_RUN=$((TESTS_RUN + 1)) + log_test "$1" +} + +pass_test() { + TESTS_PASSED=$((TESTS_PASSED + 1)) + log_pass "$1" +} + +fail_test() { + TESTS_FAILED=$((TESTS_FAILED + 1)) + log_fail "$1" +} + +# Setup test config +setup_test_config() { + # Backup existing config if it exists + if [ -f "$PROJECT_ROOT/config.local" ]; then + cp "$PROJECT_ROOT/config.local" "$PROJECT_ROOT/config.local.e2e_original_backup" + fi + + # Install test config + cp "$PROJECT_ROOT/tests/fixtures/config.local.test" "$PROJECT_ROOT/config.local" + log_pass "Test config installed" +} + +# Restore original config +restore_config() { + if [ -f "$PROJECT_ROOT/config.local.e2e_original_backup" ]; then + mv "$PROJECT_ROOT/config.local.e2e_original_backup" "$PROJECT_ROOT/config.local" + fi +} + +# Test 1: Help command works +test_help_command() { + run_test "Help command displays usage" + + if bash "$PROJECT_ROOT/srv-ctl.sh" help > /dev/null 2>&1; then + pass_test "Help command executed successfully" + else + fail_test "Help command failed" + return 1 + fi +} + +# Test 2: Validate config command works +test_validate_config() { + run_test "Validate config command" + + local output + output=$(bash "$PROJECT_ROOT/srv-ctl.sh" validate-config 2>&1) + local exit_code=$? + + if [ $exit_code -eq 0 ] && echo "$output" | grep -q "disabled"; then + pass_test "Config validation passed with expected output" + else + fail_test "Config validation failed or unexpected output" + echo "$output" + return 1 + fi +} + +# Test 3: Help command variations +test_help_variations() { + run_test "Help command variations (-h)" + + if bash "$PROJECT_ROOT/srv-ctl.sh" -h > /dev/null 2>&1; then + pass_test "-h flag shows help" + else + fail_test "-h flag failed" + return 1 + fi +} + +# Test 4: Missing config detection +test_missing_config() { + run_test "Missing config file detection" + + # Temporarily rename config + if [ -f "$PROJECT_ROOT/config.local" ]; then + mv "$PROJECT_ROOT/config.local" "$PROJECT_ROOT/config.local.backup" + fi + + local output + output=$(bash "$PROJECT_ROOT/srv-ctl.sh" validate-config 2>&1) || true + + # Restore config before checking result + if [ -f "$PROJECT_ROOT/config.local.backup" ]; then + mv "$PROJECT_ROOT/config.local.backup" "$PROJECT_ROOT/config.local" + fi + + if echo "$output" | grep -q "config.local.*not found"; then + pass_test "Missing config detected correctly" + else + fail_test "Missing config not detected: $output" + return 1 + fi +} + +# Test 5: Root check (when not root) +test_root_check() { + run_test "Root privilege check" + + if [ "$EUID" -ne 0 ]; then + local output + output=$(bash "$PROJECT_ROOT/srv-ctl.sh" start 2>&1) || true + + if echo "$output" | grep -q "run as root"; then + pass_test "Root check working correctly" + else + fail_test "Root check not working: $output" + return 1 + fi + else + pass_test "Running as root, skipping root check test" + fi +} + +# Test 6: Config with all disabled devices +test_all_disabled_config() { + run_test "Config with all devices disabled" + + local output + output=$(bash "$PROJECT_ROOT/srv-ctl.sh" validate-config 2>&1) + + if echo "$output" | grep -q "0 devices enabled"; then + pass_test "All devices correctly disabled in test config" + else + fail_test "Device count unexpected: $output" + return 1 + fi +} + +# Test 7: Test config structure validation +test_config_structure() { + run_test "Config structure validation" + + # Verify test config has expected structure and all UUIDs disabled + if grep -q "readonly PRIMARY_DATA_UUID" "$PROJECT_ROOT/config.local"; then + local enabled_count + enabled_count=$(bash "$PROJECT_ROOT/srv-ctl.sh" validate-config 2>&1 | grep -oP '\d+(?= devices enabled)' || echo "unknown") + + if [ "$enabled_count" = "0" ]; then + pass_test "Config structure valid with all devices disabled" + else + fail_test "Expected 0 enabled devices, got: $enabled_count" + return 1 + fi + else + fail_test "Config structure invalid" + return 1 + fi +} + +# Test 8: Invalid command handling +test_invalid_command() { + run_test "Invalid command handling" + + local output + output=$(bash "$PROJECT_ROOT/srv-ctl.sh" invalid-command 2>&1) || true + + # Accept either usage message or root requirement (root check happens first) + if echo "$output" | grep -qi "usage\|unknown\|invalid\|root"; then + pass_test "Invalid command shows error message" + else + fail_test "Invalid command not handled: $output" + return 1 + fi +} + +# Test 9: Config with enabled device shows correct count +test_enabled_device_count() { + run_test "Config with enabled device shows correct count" + + local temp_config="$PROJECT_ROOT/config.local" + + # Modify config to enable one device + sed -i 's/PRIMARY_DATA_UUID="none"/PRIMARY_DATA_UUID="12345678-fake-uuid-test"/' "$temp_config" + + local output + output=$(bash "$PROJECT_ROOT/srv-ctl.sh" validate-config 2>&1) + + # Restore to disabled + sed -i 's/PRIMARY_DATA_UUID="12345678-fake-uuid-test"/PRIMARY_DATA_UUID="none"/' "$temp_config" + + if echo "$output" | grep -q "1 devices enabled\|1 device enabled"; then + pass_test "Enabled device count correctly shown" + else + fail_test "Device count incorrect: $output" + return 1 + fi +} + +# Test 10: Invalid encryption type detection +test_invalid_encryption_type() { + run_test "Invalid encryption type detection" + + local temp_config="$PROJECT_ROOT/config.local" + + # Enable a device with invalid encryption type + sed -i 's/PRIMARY_DATA_UUID="none"/PRIMARY_DATA_UUID="12345678-fake-uuid-test"/' "$temp_config" + sed -i 's/PRIMARY_DATA_ENCRYPTION_TYPE="luks"/PRIMARY_DATA_ENCRYPTION_TYPE="invalid_type"/' "$temp_config" + + local output + output=$(bash "$PROJECT_ROOT/srv-ctl.sh" validate-config 2>&1) || true + local exit_code=$? + + # Restore config + sed -i 's/PRIMARY_DATA_UUID="12345678-fake-uuid-test"/PRIMARY_DATA_UUID="none"/' "$temp_config" + sed -i 's/PRIMARY_DATA_ENCRYPTION_TYPE="invalid_type"/PRIMARY_DATA_ENCRYPTION_TYPE="luks"/' "$temp_config" + + if [ $exit_code -ne 0 ] || echo "$output" | grep -qi "invalid\|unsupported\|error"; then + pass_test "Invalid encryption type detected" + else + fail_test "Invalid encryption type not detected: $output" + return 1 + fi +} + +# Test 11: No arguments shows usage +test_no_arguments() { + run_test "No arguments shows usage" + + local output + output=$(bash "$PROJECT_ROOT/srv-ctl.sh" 2>&1) || true + + if echo "$output" | grep -qi "usage"; then + pass_test "No arguments shows usage" + else + fail_test "No arguments did not show usage: $output" + return 1 + fi +} + +# Test 12: Script is executable +test_script_executable() { + run_test "Script is executable" + + if [ -x "$PROJECT_ROOT/srv-ctl.sh" ]; then + pass_test "srv-ctl.sh is executable" + else + fail_test "srv-ctl.sh is not executable" + return 1 + fi +} + +# ============================================================================ +# E2E Tests with Real Environment (require root and test environment) +# ============================================================================ + +# Test 13: E2E unlock-only command +test_e2e_unlock_only() { + run_test "E2E: unlock-only command" + + # Run unlock-only + if sudo bash "$PROJECT_ROOT/srv-ctl.sh" unlock-only 2>&1; then + # Verify device is mounted + if mountpoint -q "/mnt/$TEST_MOUNT_POINT" 2>/dev/null; then + pass_test "unlock-only successfully mounted device" + else + fail_test "Device not mounted after unlock-only" + return 1 + fi + else + fail_test "unlock-only command failed" + return 1 + fi +} + +# Test 14: E2E stop command (unmounts devices) +test_e2e_stop() { + run_test "E2E: stop command" + + # Ensure device is mounted first + if ! mountpoint -q "/mnt/$TEST_MOUNT_POINT" 2>/dev/null; then + sudo bash "$PROJECT_ROOT/srv-ctl.sh" unlock-only &>/dev/null + fi + + # Run stop + if sudo bash "$PROJECT_ROOT/srv-ctl.sh" stop 2>&1; then + # Verify device is unmounted + if ! mountpoint -q "/mnt/$TEST_MOUNT_POINT" 2>/dev/null; then + pass_test "stop successfully unmounted device" + else + fail_test "Device still mounted after stop" + return 1 + fi + else + fail_test "stop command failed" + return 1 + fi +} + +# Test 15: E2E start command (full workflow) +test_e2e_start() { + run_test "E2E: start command" + + # Ensure clean state + sudo bash "$PROJECT_ROOT/srv-ctl.sh" stop &>/dev/null || true + + # Run start + if sudo bash "$PROJECT_ROOT/srv-ctl.sh" start 2>&1; then + # Verify device is mounted + if mountpoint -q "/mnt/$TEST_MOUNT_POINT" 2>/dev/null; then + pass_test "start successfully mounted device" + else + fail_test "Device not mounted after start" + return 1 + fi + else + fail_test "start command failed" + return 1 + fi +} + +# Test 16: E2E stop-services-only command +test_e2e_stop_services_only() { + run_test "E2E: stop-services-only command" + + # Ensure device is mounted + if ! mountpoint -q "/mnt/$TEST_MOUNT_POINT" 2>/dev/null; then + sudo bash "$PROJECT_ROOT/srv-ctl.sh" unlock-only &>/dev/null + fi + + # Run stop-services-only + if sudo bash "$PROJECT_ROOT/srv-ctl.sh" stop-services-only 2>&1; then + # Verify device is still mounted (services stopped, devices not unmounted) + if mountpoint -q "/mnt/$TEST_MOUNT_POINT" 2>/dev/null; then + pass_test "stop-services-only kept device mounted" + else + fail_test "Device unexpectedly unmounted after stop-services-only" + return 1 + fi + else + fail_test "stop-services-only command failed" + return 1 + fi +} + +# Test 17: E2E idempotency - double start +test_e2e_double_start() { + run_test "E2E: double start (idempotency)" + + # Ensure clean state + sudo bash "$PROJECT_ROOT/srv-ctl.sh" stop &>/dev/null || true + + # Run start twice + sudo bash "$PROJECT_ROOT/srv-ctl.sh" start &>/dev/null + if sudo bash "$PROJECT_ROOT/srv-ctl.sh" start 2>&1; then + # Verify device is still mounted + if mountpoint -q "/mnt/$TEST_MOUNT_POINT" 2>/dev/null; then + pass_test "double start handled gracefully" + else + fail_test "Device not mounted after double start" + return 1 + fi + else + fail_test "double start command failed" + return 1 + fi +} + +# Test 18: E2E idempotency - double stop +test_e2e_double_stop() { + run_test "E2E: double stop (idempotency)" + + # Ensure started state + sudo bash "$PROJECT_ROOT/srv-ctl.sh" start &>/dev/null || true + + # Run stop twice + sudo bash "$PROJECT_ROOT/srv-ctl.sh" stop &>/dev/null + if sudo bash "$PROJECT_ROOT/srv-ctl.sh" stop 2>&1; then + # Verify device is unmounted + if ! mountpoint -q "/mnt/$TEST_MOUNT_POINT" 2>/dev/null; then + pass_test "double stop handled gracefully" + else + fail_test "Device still mounted after double stop" + return 1 + fi + else + fail_test "double stop command failed" + return 1 + fi +} + +# Setup E2E test environment (reuses integration test setup) +setup_e2e_environment() { + if [ "$EUID" -ne 0 ]; then + log_fail "E2E tests require root privileges. Skipping E2E tests." + return 1 + fi + + # Run the integration test setup + echo "Setting up E2E test environment..." + if ! sudo bash "$PROJECT_ROOT/tests/fixtures/setup-test-env.sh"; then + log_fail "Failed to setup test environment" + if [ -f "$PROJECT_ROOT/tests/fixtures/cleanup-test-env.sh" ]; then + sudo bash "$PROJECT_ROOT/tests/fixtures/cleanup-test-env.sh" || true + fi + return 1 + fi + + # Load test environment variables + if [ -f /tmp/test_env.conf ]; then + source /tmp/test_env.conf + + # Create key file for automated unlocking + local key_file="/tmp/test_key.key" + echo -n "$TEST_PASSWORD" > "$key_file" + chmod 600 "$key_file" + + + # Create E2E config that uses the test environment + + cat > "$PROJECT_ROOT/config.local" </dev/null || true + + # Cleanup integration test environment + if [ -f "$PROJECT_ROOT/tests/fixtures/cleanup-test-env.sh" ]; then + sudo bash "$PROJECT_ROOT/tests/fixtures/cleanup-test-env.sh" &>/dev/null || true + fi + fi +} + +# Main +main() { + echo "=========================================" + echo "End-to-End Tests for srv-ctl" + echo "=========================================" + echo "" + + setup_test_config + + test_help_command + test_validate_config + test_help_variations + test_missing_config + test_root_check + test_all_disabled_config + test_config_structure + test_invalid_command + test_enabled_device_count + test_invalid_encryption_type + test_no_arguments + test_script_executable + + # E2E tests with real environment (require root) + echo "" + echo "=========================================" + echo "E2E Tests with Real Environment" + echo "=========================================" + echo "" + + if setup_e2e_environment; then + test_e2e_unlock_only + test_e2e_stop + test_e2e_start + test_e2e_stop_services_only + test_e2e_double_start + test_e2e_double_stop + + cleanup_e2e_environment + else + # If running as root (which we should be in CI), setup failure is a test failure + if [ "$EUID" -eq 0 ]; then + echo "E2E test environment setup failed (this is a test failure in CI)" + TESTS_FAILED=$((TESTS_FAILED + 1)) + else + echo "Skipping E2E tests (requires root)" + fi + fi + + # Restore original config + restore_config + + echo "" + echo "=========================================" + echo "Test Results" + echo "=========================================" + echo "Tests run: $TESTS_RUN" + echo "Tests passed: $TESTS_PASSED" + echo "Tests failed: $TESTS_FAILED" + echo "" + + if [ $TESTS_FAILED -eq 0 ]; then + echo -e "${GREEN}All tests passed!${NC}" + exit 0 + else + echo -e "${RED}Some tests failed!${NC}" + exit 1 + fi +} + +main "$@" diff --git a/tests/fixtures/cleanup-test-env.sh b/tests/fixtures/cleanup-test-env.sh new file mode 100755 index 0000000..21c8884 --- /dev/null +++ b/tests/fixtures/cleanup-test-env.sh @@ -0,0 +1,132 @@ +#!/bin/bash +# Cleanup script for integration tests +# Removes all test resources created by setup-test-env.sh + +set -euo pipefail + +# Test configuration (must match setup-test-env.sh) +readonly TEST_LUKS_NAME="test_luks" +readonly TEST_VG_NAME="test_vg" +readonly TEST_LV_NAME="test_lv" +readonly TEST_MOUNT_POINT="/tmp/test_mount" + +# Colors for output +readonly RED='\033[0;31m' +readonly GREEN='\033[0;32m' +readonly YELLOW='\033[1;33m' +readonly NC='\033[0m' # No Color + +log_info() { + echo -e "${GREEN}[INFO]${NC} $*" >&2 +} + +log_warn() { + echo -e "${YELLOW}[WARN]${NC} $*" >&2 +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $*" >&2 +} + +# Check if running with required privileges +check_privileges() { + if [[ $EUID -ne 0 ]]; then + log_error "This script must be run as root or with sudo" + exit 1 + fi +} + +# Unmount test mount point +unmount_test_volume() { + if mountpoint -q "$TEST_MOUNT_POINT" 2>/dev/null; then + log_info "Unmounting $TEST_MOUNT_POINT..." + umount "$TEST_MOUNT_POINT" || log_warn "Failed to unmount $TEST_MOUNT_POINT" + fi +} + +# Remove mount point +remove_mount_point() { + if [[ -d "$TEST_MOUNT_POINT" ]]; then + log_info "Removing mount point $TEST_MOUNT_POINT..." + rmdir "$TEST_MOUNT_POINT" 2>/dev/null || log_warn "Failed to remove $TEST_MOUNT_POINT" + fi +} + +# Deactivate LVM +deactivate_lvm() { + if lvs "$TEST_VG_NAME/$TEST_LV_NAME" &>/dev/null; then + log_info "Deactivating LVM..." + lvchange -an "$TEST_VG_NAME/$TEST_LV_NAME" 2>/dev/null || log_warn "Failed to deactivate LV" + fi + + if vgs "$TEST_VG_NAME" &>/dev/null; then + log_info "Removing volume group..." + vgremove -f "$TEST_VG_NAME" 2>/dev/null || log_warn "Failed to remove VG" + fi + + # Remove any remaining physical volumes + for pv in $(pvs --noheadings -o pv_name 2>/dev/null | grep mapper || true); do + log_info "Removing physical volume $pv..." + pvremove -f "$pv" 2>/dev/null || log_warn "Failed to remove PV $pv" + done +} + +# Close LUKS container +close_luks() { + if [[ -e "/dev/mapper/$TEST_LUKS_NAME" ]]; then + log_info "Closing LUKS container..." + cryptsetup close "$TEST_LUKS_NAME" || log_warn "Failed to close LUKS container" + fi +} + +# Detach loop devices +detach_loop_devices() { + log_info "Detaching loop devices..." + + # Remove UUID symlinks for loop devices + if [[ -f /tmp/test_env.conf ]]; then + source /tmp/test_env.conf 2>/dev/null || true + if [[ -n "${TEST_LOOP_UUID:-}" && -L "/dev/disk/by-uuid/$TEST_LOOP_UUID" ]]; then + log_info "Removing UUID symlink /dev/disk/by-uuid/$TEST_LOOP_UUID..." + rm -f "/dev/disk/by-uuid/$TEST_LOOP_UUID" + fi + fi + + # Find and detach all loop devices using our test file + for loop_dev in $(losetup -j /tmp/test_loop.img 2>/dev/null | cut -d: -f1); do + log_info "Detaching $loop_dev..." + losetup -d "$loop_dev" || log_warn "Failed to detach $loop_dev" + done + + # Remove the loop file + if [[ -f /tmp/test_loop.img ]]; then + log_info "Removing loop file..." + rm -f /tmp/test_loop.img + fi +} + +# Remove test configuration +remove_test_config() { + if [[ -f /tmp/test_env.conf ]]; then + log_info "Removing test configuration..." + rm -f /tmp/test_env.conf + fi +} + +# Main cleanup +main() { + log_info "Cleaning up integration test environment..." + + check_privileges + + unmount_test_volume + remove_mount_point + deactivate_lvm + close_luks + detach_loop_devices + remove_test_config + + log_info "Test environment cleanup complete!" +} + +main "$@" diff --git a/tests/fixtures/config.local.test b/tests/fixtures/config.local.test new file mode 100644 index 0000000..265bd74 --- /dev/null +++ b/tests/fixtures/config.local.test @@ -0,0 +1,96 @@ +# ----------------------------------------------------------------------------- +# TEST CONFIGURATION - Safe values for CI/CD testing +# ----------------------------------------------------------------------------- +# This file is used by automated tests. All devices are disabled by default +# for safety. Tests will override specific values as needed. + +# Minimum cryptsetup version required (2.4.0+ supports modern unified syntax) +readonly CRYPTSETUP_MIN_VERSION="2.4.0" + +# Syncthing users (set to "none" to disable) +readonly ST_USER_1="none" +readonly ST_USER_2="none" + +# Service names (automatically constructed from user names) +readonly ST_SERVICE_1=$([ "$ST_USER_1" != "none" ] && echo "syncthing@${ST_USER_1}.service" || echo "none") +readonly ST_SERVICE_2=$([ "$ST_USER_2" != "none" ] && echo "syncthing@${ST_USER_2}.service" || echo "none") +readonly DOCKER_SERVICE="none" + +# ----------------------------------------------------------------------------- +# Primary data device configuration - DISABLED FOR TESTS +# ----------------------------------------------------------------------------- + +readonly PRIMARY_DATA_MOUNT="test-primary" +readonly PRIMARY_DATA_MAPPER="test-primary-data" +readonly PRIMARY_DATA_LVM_NAME="none" +readonly PRIMARY_DATA_LVM_GROUP="vg-test" +readonly PRIMARY_DATA_UUID="none" # Disabled for safety +readonly PRIMARY_DATA_KEY_FILE="none" +readonly PRIMARY_DATA_ENCRYPTION_TYPE="luks" +readonly PRIMARY_DATA_OWNER_USER="none" +readonly PRIMARY_DATA_OWNER_GROUP="none" +readonly PRIMARY_DATA_MOUNT_OPTIONS="defaults" + +# ----------------------------------------------------------------------------- +# Storage devices for Syncthing service 1 - DISABLED FOR TESTS +# ----------------------------------------------------------------------------- + +readonly STORAGE_1A_MOUNT="test-storage1a" +readonly STORAGE_1A_MAPPER="test-storage1a-data" +readonly STORAGE_1A_LVM_NAME="none" +readonly STORAGE_1A_LVM_GROUP="vg-test" +readonly STORAGE_1A_UUID="none" # Disabled for safety +readonly STORAGE_1A_KEY_FILE="none" +readonly STORAGE_1A_ENCRYPTION_TYPE="luks" +readonly STORAGE_1A_OWNER_USER="none" +readonly STORAGE_1A_OWNER_GROUP="none" +readonly STORAGE_1A_MOUNT_OPTIONS="defaults" + +readonly STORAGE_1B_MOUNT="test-storage1b" +readonly STORAGE_1B_MAPPER="test-storage1b-data" +readonly STORAGE_1B_LVM_NAME="none" +readonly STORAGE_1B_LVM_GROUP="vg-test" +readonly STORAGE_1B_UUID="none" # Disabled for safety +readonly STORAGE_1B_KEY_FILE="none" +readonly STORAGE_1B_ENCRYPTION_TYPE="luks" +readonly STORAGE_1B_OWNER_USER="none" +readonly STORAGE_1B_OWNER_GROUP="none" +readonly STORAGE_1B_MOUNT_OPTIONS="defaults" + +# ----------------------------------------------------------------------------- +# Storage devices for Syncthing service 2 - DISABLED FOR TESTS +# ----------------------------------------------------------------------------- + +readonly STORAGE_2A_MOUNT="test-storage2a" +readonly STORAGE_2A_MAPPER="test-storage2a-data" +readonly STORAGE_2A_LVM_NAME="none" +readonly STORAGE_2A_LVM_GROUP="vg-test" +readonly STORAGE_2A_UUID="none" # Disabled for safety +readonly STORAGE_2A_KEY_FILE="none" +readonly STORAGE_2A_ENCRYPTION_TYPE="luks" +readonly STORAGE_2A_OWNER_USER="none" +readonly STORAGE_2A_OWNER_GROUP="none" +readonly STORAGE_2A_MOUNT_OPTIONS="defaults" + +readonly STORAGE_2B_MOUNT="test-storage2b" +readonly STORAGE_2B_MAPPER="test-storage2b-data" +readonly STORAGE_2B_LVM_NAME="none" +readonly STORAGE_2B_LVM_GROUP="vg-test" +readonly STORAGE_2B_UUID="none" # Disabled for safety +readonly STORAGE_2B_KEY_FILE="none" +readonly STORAGE_2B_ENCRYPTION_TYPE="luks" +readonly STORAGE_2B_OWNER_USER="none" +readonly STORAGE_2B_OWNER_GROUP="none" +readonly STORAGE_2B_MOUNT_OPTIONS="defaults" + +# ----------------------------------------------------------------------------- +# Network share configuration - DISABLED FOR TESTS +# ----------------------------------------------------------------------------- + +readonly NETWORK_SHARE_ADDRESS="none" +readonly NETWORK_SHARE_MOUNT="test-network" +readonly NETWORK_SHARE_PROTOCOL="none" +readonly NETWORK_SHARE_CREDENTIALS="none" +readonly NETWORK_SHARE_OWNER_USER="none" +readonly NETWORK_SHARE_OWNER_GROUP="none" +readonly NETWORK_SHARE_OPTIONS="iocharset=utf8" diff --git a/tests/fixtures/setup-test-env.sh b/tests/fixtures/setup-test-env.sh new file mode 100755 index 0000000..fdc5da8 --- /dev/null +++ b/tests/fixtures/setup-test-env.sh @@ -0,0 +1,303 @@ +#!/bin/bash +# Setup script for integration tests +# Creates loop devices, LUKS containers, and LVM volumes for testing + +set -euo pipefail + +# Test configuration +readonly TEST_LOOP_SIZE_MB=100 +readonly TEST_LUKS_NAME="test_luks" +readonly TEST_VG_NAME="test_vg" +readonly TEST_LV_NAME="test_lv" +readonly TEST_MOUNT_POINT="/tmp/test_mount" +readonly TEST_PASSWORD="test123456" + +# Colors for output +readonly RED='\033[0;31m' +readonly GREEN='\033[0;32m' +readonly YELLOW='\033[1;33m' +readonly NC='\033[0m' # No Color + +log_info() { + echo -e "${GREEN}[INFO]${NC} $*" >&2 +} + +log_warn() { + echo -e "${YELLOW}[WARN]${NC} $*" >&2 +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $*" >&2 +} +# Check if running with required privileges +check_privileges() { + if [[ $EUID -ne 0 ]]; then + log_error "This script must be run as root or with sudo" + exit 1 + fi +} + +# Install required packages +install_dependencies() { + + export DEBIAN_FRONTEND=noninteractive + export LANG=C.UTF-8 + export LC_ALL=C.UTF-8 + + log_info "Installing test dependencies..." + + exfat_pkg="exfatprogs" # default for modern distros + if command -v apt-get &> /dev/null; then + # Purge problematic packages on Debian 10 before any install + . /etc/os-release + if [[ "$ID" == "debian" && ${VERSION_ID%%.*} -eq 10 ]]; then + log_warn "Purging console-setup and keyboard-configuration to avoid dpkg errors (Debian 10 workaround)..." + apt-get remove --purge -y console-setup keyboard-configuration || true + dpkg --configure -a || true + fi + # Detect Ubuntu/Debian version for exFAT package + . /etc/os-release + if [[ ( "$ID" == "ubuntu" && ${VERSION_ID%%.*} -lt 22 ) || ( "$ID" == "debian" && ${VERSION_ID%%.*} -lt 11 ) ]]; then + exfat_pkg="exfat-utils" + fi + # Fix apt sources for Debian 10 (buster) to use archive.debian.org + if [[ "$ID" == "debian" && ${VERSION_ID%%.*} -eq 10 ]]; then + log_info "Rewriting apt sources for Debian 10 (buster) archive..." + sed -i 's|http://deb.debian.org/debian|http://archive.debian.org/debian|g' /etc/apt/sources.list + sed -i 's|http://security.debian.org/debian-security|http://archive.debian.org/debian-security|g' /etc/apt/sources.list + echo 'Acquire::Check-Valid-Until "false";' > /etc/apt/apt.conf.d/99no-check-valid-until + fi + apt-get update -qq + apt-get install -y -qq \ + cryptsetup \ + lvm2 \ + dosfstools \ + ntfs-3g \ + exfat-fuse \ + "$exfat_pkg" \ + util-linux + elif command -v yum &> /dev/null; then + yum install -y -q \ + cryptsetup \ + lvm2 \ + dosfstools \ + ntfs-3g \ + exfat-utils \ + util-linux + else + log_error "Unsupported package manager" + exit 1 + fi + + # Workaround for console-setup errors on old Debian + if [[ -f /var/lib/dpkg/info/console-setup.postinst ]]; then + log_warn "Attempting to fix or remove broken console-setup package (Debian 10 workaround)..." + dpkg --configure -a || true + apt-get -f install || true + dpkg-reconfigure console-setup || true + apt-get remove --purge -y console-setup || true + fi + + log_info "Dependencies installed" +} + +# Create a loop device for testing +create_loop_device() { + log_info "Creating loop device (${TEST_LOOP_SIZE_MB}MB)..." + + # Create a file for the loop device + local loop_file="/tmp/test_loop.img" + dd if=/dev/zero of="$loop_file" bs=1M count=$TEST_LOOP_SIZE_MB status=none + + # Setup loop device + local loop_dev + loop_dev=$(losetup -f) + losetup "$loop_dev" "$loop_file" + + log_info "Loop device created: $loop_dev" + echo "$loop_dev" +} + +# Create LUKS container on loop device +create_luks_container() { + local loop_dev=$1 + + log_info "Creating LUKS container on $loop_dev..." + + # Verify device exists and is ready + if [[ ! -b "$loop_dev" ]]; then + log_error "Loop device $loop_dev does not exist or is not a block device" + return 1 + fi + + # Wait for device to be ready + sleep 1 + + # Format as LUKS + echo -n "$TEST_PASSWORD" | cryptsetup luksFormat --type luks2 "$loop_dev" - + + # Trigger udev to create symlinks + udevadm settle + + # Open the LUKS container + echo -n "$TEST_PASSWORD" | cryptsetup open "$loop_dev" "$TEST_LUKS_NAME" - + + log_info "LUKS container created and opened as /dev/mapper/$TEST_LUKS_NAME" +} + +# Create LVM on LUKS container +create_lvm_on_luks() { + log_info "Creating LVM on LUKS container..." + + local luks_dev="/dev/mapper/$TEST_LUKS_NAME" + + # Verify LUKS device exists + if [[ ! -b "$luks_dev" ]]; then + log_error "LUKS device not found: $luks_dev" + return 1 + fi + + # Create physical volume + pvcreate "$luks_dev" + + # Wait for device to be ready + udevadm settle + + # Create volume group + vgcreate "$TEST_VG_NAME" "$luks_dev" + + # Wait for VG to be ready + udevadm settle + vgscan --mknodes + + # Create logical volume (skip zeroing for speed and don't activate yet) + lvcreate -Zn -an -L 70M -n "$TEST_LV_NAME" "$TEST_VG_NAME" -y + + log_info "DEBUG: LV created (inactive), checking lvs..." + lvs + + # Activate the logical volume + log_info "DEBUG: Activating LV..." + lvchange -ay "$TEST_VG_NAME/$TEST_LV_NAME" + + log_info "DEBUG: Waiting for device nodes..." + udevadm settle + sleep 2 + + log_info "DEBUG: Creating device nodes explicitly..." + dmsetup mknodes + vgmknodes + + log_info "DEBUG: Checking all possible device paths..." + ls -la "/dev/$TEST_VG_NAME/" 2>/dev/null || log_warn "VG directory still not found" + ls -la /dev/mapper/test* 2>/dev/null || log_warn "No test devices in mapper" + + # Check if the mapper device exists (primary path) + local mapper_path="/dev/mapper/$TEST_VG_NAME-$TEST_LV_NAME" + if [[ ! -b "$mapper_path" ]]; then + log_error "LV mapper device not found at $mapper_path" + log_error "Trying to force device creation..." + dmsetup ls --tree + lvchange -ay --yes "$TEST_VG_NAME/$TEST_LV_NAME" + udevadm settle + dmsetup mknodes + sleep 1 + ls -la /dev/mapper/test* 2>/dev/null || true + fi + + log_info "DEBUG: Final device check..." + ls -la "/dev/$TEST_VG_NAME/$TEST_LV_NAME" 2>/dev/null || log_warn "Symlink not found, but mapper may exist" + ls -la "$mapper_path" || log_error "LV mapper device still not found after all attempts" + + # Trigger udev to create symlinks + udevadm settle + + # Format with ext4 + mkfs.ext4 -q "/dev/$TEST_VG_NAME/$TEST_LV_NAME" + + # Trigger udev again after formatting + udevadm settle + + log_info "LVM created: /dev/$TEST_VG_NAME/$TEST_LV_NAME" +} + +# Create mount point +create_mount_point() { + log_info "Creating test mount point: $TEST_MOUNT_POINT" + mkdir -p "$TEST_MOUNT_POINT" +} + +# Export test configuration +export_test_config() { + local loop_dev=$1 + local config_file="/tmp/test_env.conf" + + # Get UUID of the loop device (for unlock_device function) + local loop_uuid + loop_uuid=$(blkid -s UUID -o value "$loop_dev") + if [ -z "$loop_uuid" ]; then + log_error "Failed to get UUID for loop device $loop_dev" + return "$FAILURE" + fi + + # Create /dev/disk/by-uuid symlink manually for loop device (udev doesn't do this) + mkdir -p /dev/disk/by-uuid + ln -sf "$loop_dev" "/dev/disk/by-uuid/$loop_uuid" + log_info "Created UUID symlink: /dev/disk/by-uuid/$loop_uuid -> $loop_dev" + + # Get UUID of the LVM logical volume (for mount tests) + local lv_uuid + lv_uuid=$(blkid -s UUID -o value "/dev/$TEST_VG_NAME/$TEST_LV_NAME") + if [ -z "$lv_uuid" ]; then + log_error "Failed to get UUID for LV /dev/$TEST_VG_NAME/$TEST_LV_NAME" + return "$FAILURE" + fi + + cat > "$config_file" <&2 || true; } + +# Trap errors to show what failed +trap 'echo "ERROR: Command failed at line $LINENO: $BASH_COMMAND" >&2' ERR + +# Load test environment +debug "Loading test environment from /tmp/test_env.conf" +if [[ ! -f /tmp/test_env.conf ]]; then + echo "ERROR: Test environment not setup. Run setup-test-env.sh first." + exit 1 +fi +source /tmp/test_env.conf + +# Load libraries +export SUCCESS=0 +export FAILURE=1 +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../../lib/os-utils.sh" +source "$SCRIPT_DIR/../../lib/storage.sh" +debug "Libraries loaded successfully" + +# Test counters +TESTS_RUN=0 +TESTS_PASSED=0 +TESTS_FAILED=0 + +# Colors +readonly RED='\033[0;31m' +readonly GREEN='\033[0;32m' +readonly YELLOW='\033[1;33m' +readonly NC='\033[0m' + +log_test() { + echo -e "${YELLOW}[TEST]${NC} $*" +} + +log_pass() { + echo -e "${GREEN}[PASS]${NC} $*" +} + +log_fail() { + echo -e "${RED}[FAIL]${NC} $*" +} + +log_warn() { + echo -e "${YELLOW}[WARN]${NC} $*" +} + +run_test() { + ((TESTS_RUN++)) + log_test "$1" +} + +pass_test() { + ((TESTS_PASSED++)) || true +} + +fail_test() { + ((TESTS_FAILED++)) || true +} + +# Test 1: Close and reopen LUKS container +test_luks_lock_unlock() { + debug "Inside test_luks_lock_unlock" + run_test "LUKS lock and unlock" + + # Deactivate LVM first (LUKS can't be closed while in use) + lvchange -an "$TEST_VG_NAME/$TEST_LV_NAME" 2>/dev/null && log_pass "Deactivated LVM logical volume" || log_warn "LVM already inactive or deactivation failed" + vgchange -an "$TEST_VG_NAME" 2>/dev/null && log_pass "Deactivated volume group" || log_warn "VG already inactive" + + # Wait for device to be released + sleep 1 + udevadm settle + + debug "About to call lock_device" + if lock_device "$TEST_LUKS_MAPPER" "luks"; then + log_pass "Successfully closed LUKS container" + else + log_fail "Failed to close LUKS container" + return "$FAILURE" + fi + + # Verify it's closed + if [[ ! -e "/dev/mapper/$TEST_LUKS_MAPPER" ]]; then + log_pass "LUKS container is closed" + else + log_fail "LUKS container still exists after close" + return "$FAILURE" + fi + + # Reopen LUKS using UUID + if echo -n "$TEST_PASSWORD" | unlock_device "$TEST_LOOP_UUID" "$TEST_LUKS_MAPPER" "none" "luks"; then + log_pass "Successfully reopened LUKS container" + else + log_fail "Failed to reopen LUKS container" + return "$FAILURE" + fi + + # Verify it's open + if [[ -e "/dev/mapper/$TEST_LUKS_MAPPER" ]]; then + log_pass "LUKS container is open" + else + log_fail "LUKS container does not exist after open" + return "$FAILURE" + fi + + # Reactivate LVM for subsequent tests + vgchange -ay "$TEST_VG_NAME" 2>/dev/null && log_pass "Reactivated volume group" || log_warn "Failed to reactivate VG" + lvchange -ay "$TEST_VG_NAME/$TEST_LV_NAME" 2>/dev/null && log_pass "Reactivated LVM after reopening LUKS" || log_warn "Failed to reactivate LVM" + + return "$SUCCESS" +} + +# Test 2: Wrong password handling +test_luks_wrong_password() { + run_test "LUKS wrong password handling" + + # Deactivate LVM completely (LV + VG) + lvchange -an "$TEST_VG_NAME/$TEST_LV_NAME" 2>/dev/null && log_pass "Deactivated LVM logical volume" || log_warn "Failed to deactivate LVM" + vgchange -an "$TEST_VG_NAME" 2>/dev/null && log_pass "Deactivated volume group" || log_warn "Failed to deactivate VG" + + # Wait for device to be released + sleep 1 + udevadm settle + + if lock_device "$TEST_LUKS_MAPPER" "luks"; then + log_pass "Closed LUKS container" + else + log_fail "Failed to close LUKS" + return "$FAILURE" + fi + + # Try to open with wrong password + if echo -n "wrongpassword" | unlock_device "$TEST_LOOP_UUID" "$TEST_LUKS_MAPPER" "none" "luks" 2>/dev/null; then + log_fail "LUKS opened with wrong password (should have failed)" + return "$FAILURE" + else + log_pass "LUKS correctly rejected wrong password" + fi + + # Reopen with correct password for subsequent tests + echo -n "$TEST_PASSWORD" | unlock_device "$TEST_LOOP_UUID" "$TEST_LUKS_MAPPER" "none" "luks" &>/dev/null + vgchange -ay "$TEST_VG_NAME" 2>/dev/null || true + lvchange -ay "$TEST_VG_NAME/$TEST_LV_NAME" 2>/dev/null || true + + return "$SUCCESS" +} + +# Test 3: Double close handling +test_luks_double_close() { + run_test "LUKS double close handling" + + # Deactivate LVM completely + lvchange -an "$TEST_VG_NAME/$TEST_LV_NAME" 2>/dev/null || true + vgchange -an "$TEST_VG_NAME" 2>/dev/null || true + sleep 1 + udevadm settle + + # Close once + lock_device "$TEST_LUKS_MAPPER" "luks" &>/dev/null + + # Try to close again (library should skip if already closed) + if lock_device "$TEST_LUKS_MAPPER" "luks" 2>/dev/null; then + log_pass "Double close handled gracefully" + else + log_fail "Double close returned error" + return "$FAILURE" + fi + + # Reopen for subsequent tests + echo -n "$TEST_PASSWORD" | unlock_device "$TEST_LOOP_UUID" "$TEST_LUKS_MAPPER" "none" "luks" &>/dev/null + vgchange -ay "$TEST_VG_NAME" 2>/dev/null || true + lvchange -ay "$TEST_VG_NAME/$TEST_LV_NAME" 2>/dev/null || true + + return "$SUCCESS" +} + +# Run all tests +main() { + echo "=========================================" + echo "LUKS Integration Tests" + echo "=========================================" + echo "" + + debug "Starting test 1..." + if test_luks_lock_unlock; then + pass_test + debug "Test 1 passed" + else + fail_test + debug "Test 1 failed" + fi + + debug "Starting test 2..." + if test_luks_wrong_password; then + pass_test + debug "Test 2 passed" + else + fail_test + debug "Test 2 failed" + fi + + debug "Starting test 3..." + if test_luks_double_close; then + pass_test + debug "Test 3 passed" + else + fail_test + debug "Test 3 failed" + fi + + debug "All tests completed, showing summary..." + + echo "" + echo "=========================================" + echo "Test Results" + echo "=========================================" + echo "Tests run: $TESTS_RUN" + echo "Tests passed: $TESTS_PASSED" + echo "Tests failed: $TESTS_FAILED" + echo "" + + if [[ $TESTS_FAILED -eq 0 ]]; then + echo -e "${GREEN}All tests passed!${NC}" + exit 0 + else + echo -e "${RED}Some tests failed!${NC}" + exit 1 + fi +} + +main "$@" diff --git a/tests/integration/test-lvm.sh b/tests/integration/test-lvm.sh new file mode 100755 index 0000000..7282339 --- /dev/null +++ b/tests/integration/test-lvm.sh @@ -0,0 +1,225 @@ +#!/bin/bash +# Integration tests for LVM operations + +set -euo pipefail + +DEBUG="${TEST_DEBUG:-0}" +debug() { [[ "$DEBUG" == "1" ]] && echo "DEBUG: $*" >&2 || true; } + +# Trap errors to show what failed +trap 'echo "ERROR: Command failed at line $LINENO: $BASH_COMMAND" >&2' ERR + +# Load test environment +debug "Loading test environment from /tmp/test_env.conf" +if [[ ! -f /tmp/test_env.conf ]]; then + echo "ERROR: Test environment not setup. Run setup-test-env.sh first." + exit 1 +fi +source /tmp/test_env.conf + +# Load libraries +export SUCCESS=0 +export FAILURE=1 +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../../lib/os-utils.sh" +source "$SCRIPT_DIR/../../lib/storage.sh" +debug "Libraries loaded successfully" + +# Test counters +TESTS_RUN=0 +TESTS_PASSED=0 +TESTS_FAILED=0 + +# Colors +readonly RED='\033[0;31m' +readonly GREEN='\033[0;32m' +readonly YELLOW='\033[1;33m' +readonly NC='\033[0m' + +log_test() { + echo -e "${YELLOW}[TEST]${NC} $*" +} + +log_pass() { + echo -e "${GREEN}[PASS]${NC} $*" +} + +log_fail() { + echo -e "${RED}[FAIL]${NC} $*" +} + +log_warn() { + echo -e "${YELLOW}[WARN]${NC} $*" +} + +run_test() { + ((TESTS_RUN++)) + log_test "$1" +} + +pass_test() { + ((TESTS_PASSED++)) || true +} + +fail_test() { + ((TESTS_FAILED++)) || true +} + +# Test 1: Verify LVM +test_lvm_verify() { + run_test "LVM verification" + + if verify_lvm "$TEST_LV_NAME" "$TEST_VG_NAME"; then + log_pass "LVM verification successful" + else + log_fail "LVM verification failed" + return "$FAILURE" + fi + + return "$SUCCESS" +} + +# Test 2: Check if LVM is active +test_lvm_is_active() { + run_test "LVM active check" + + if lvm_is_active "$TEST_LV_NAME" "$TEST_VG_NAME"; then + log_pass "LVM is active" + else + log_fail "LVM is not active" + return "$FAILURE" + fi + + return "$SUCCESS" +} + +# Test 3: Deactivate and reactivate LVM +test_lvm_deactivate_activate() { + run_test "LVM deactivate and reactivate" + + # Deactivate + if deactivate_lvm "$TEST_LV_NAME" "$TEST_VG_NAME"; then + log_pass "Successfully deactivated LVM" + else + log_fail "Failed to deactivate LVM" + return "$FAILURE" + fi + + # Verify it's inactive + if ! lvm_is_active "$TEST_LV_NAME" "$TEST_VG_NAME"; then + log_pass "LVM is inactive" + else + log_fail "LVM is still active after deactivation" + return "$FAILURE" + fi + + # Reactivate + if activate_lvm "$TEST_LV_NAME" "$TEST_VG_NAME"; then + log_pass "Successfully reactivated LVM" + else + log_fail "Failed to reactivate LVM" + return "$FAILURE" + fi + + # Verify it's active + if lvm_is_active "$TEST_LV_NAME" "$TEST_VG_NAME"; then + log_pass "LVM is active after reactivation" + else + log_fail "LVM is not active after reactivation" + return "$FAILURE" + fi + + return "$SUCCESS" +} + +# Test 4: Double deactivation handling +test_lvm_double_deactivate() { + run_test "LVM double deactivation handling" + + # Deactivate once + deactivate_lvm "$TEST_LV_NAME" "$TEST_VG_NAME" &>/dev/null + + # Try to deactivate again + if deactivate_lvm "$TEST_LV_NAME" "$TEST_VG_NAME" 2>/dev/null; then + log_pass "Double deactivation handled gracefully" + else + log_fail "Double deactivation returned error" + return "$FAILURE" + fi + + # Reactivate for subsequent tests + activate_lvm "$TEST_LV_NAME" "$TEST_VG_NAME" &>/dev/null + + return "$SUCCESS" +} + +# Test 5: Verify nonexistent VG/LV +test_lvm_verify_nonexistent() { + run_test "LVM verify nonexistent volume" + + if verify_lvm "nonexistent_lv" "nonexistent_vg" 2>/dev/null; then + log_fail "verify_lvm succeeded for nonexistent volume (should fail)" + return "$FAILURE" + else + log_pass "verify_lvm correctly failed for nonexistent volume" + fi + + return "$SUCCESS" +} + +# Run all tests +main() { + echo "=========================================" + echo "LVM Integration Tests" + echo "=========================================" + echo "" + + if test_lvm_verify; then + pass_test + else + fail_test + fi + + if test_lvm_is_active; then + pass_test + else + fail_test + fi + + if test_lvm_deactivate_activate; then + pass_test + else + fail_test + fi + + if test_lvm_double_deactivate; then + pass_test + else + fail_test + fi + + if test_lvm_verify_nonexistent; then + pass_test + else + fail_test + fi + + echo "" + echo "=========================================" + echo "Test Results" + echo "=========================================" + echo "Tests run: $TESTS_RUN" + echo "Tests passed: $TESTS_PASSED" + echo "Tests failed: $TESTS_FAILED" + echo "" + + if [[ $TESTS_FAILED -eq 0 ]]; then + echo -e "${GREEN}All tests passed!${NC}" + exit 0 + else + echo -e "${RED}Some tests failed!${NC}" + exit 1 + fi +} + +main "$@" diff --git a/tests/integration/test-mount.sh b/tests/integration/test-mount.sh new file mode 100755 index 0000000..6215095 --- /dev/null +++ b/tests/integration/test-mount.sh @@ -0,0 +1,271 @@ +#!/bin/bash +# Integration tests for mount operations + +set -euo pipefail + +DEBUG="${TEST_DEBUG:-0}" +debug() { [[ "$DEBUG" == "1" ]] && echo "DEBUG: $*" >&2 || true; } + +# Trap errors to show what failed +trap 'echo "ERROR: Command failed at line $LINENO: $BASH_COMMAND" >&2' ERR + +# Load test environment +debug "Loading test environment from /tmp/test_env.conf" +if [[ ! -f /tmp/test_env.conf ]]; then + echo "ERROR: Test environment not setup. Run setup-test-env.sh first." + exit 1 +fi +source /tmp/test_env.conf + +# Load libraries +export SUCCESS=0 +export FAILURE=1 +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../../lib/os-utils.sh" +source "$SCRIPT_DIR/../../lib/storage.sh" +debug "Libraries loaded successfully" + +# Test counters +TESTS_RUN=0 +TESTS_PASSED=0 +TESTS_FAILED=0 + +# Colors +readonly RED='\033[0;31m' +readonly GREEN='\033[0;32m' +readonly YELLOW='\033[1;33m' +readonly NC='\033[0m' + +log_test() { + echo -e "${YELLOW}[TEST]${NC} $*" +} + +log_pass() { + echo -e "${GREEN}[PASS]${NC} $*" +} + +log_fail() { + echo -e "${RED}[FAIL]${NC} $*" +} + +log_warn() { + echo -e "${YELLOW}[WARN]${NC} $*" +} + +run_test() { + ((TESTS_RUN++)) + log_test "$1" +} + +pass_test() { + ((TESTS_PASSED++)) || true +} + +fail_test() { + ((TESTS_FAILED++)) || true +} + +# Test 1: Mount and unmount device +test_mount_unmount() { + run_test "Mount and unmount device" + + if mount_device "$TEST_LV_MAPPER" "$TEST_MOUNT_POINT" "defaults"; then + log_pass "Successfully mounted device" + else + log_fail "Failed to mount device" + return "$FAILURE" + fi + + # Verify it's mounted + if mountpoint -q "/mnt/$TEST_MOUNT_POINT"; then + log_pass "Device is mounted" + else + log_fail "Device is not mounted" + return "$FAILURE" + fi + + if unmount_device "$TEST_MOUNT_POINT"; then + log_pass "Successfully unmounted device" + else + log_fail "Failed to unmount device" + return "$FAILURE" + fi + + # Verify it's unmounted + if ! mountpoint -q "/mnt/$TEST_MOUNT_POINT"; then + log_pass "Device is unmounted" + else + log_fail "Device is still mounted" + return "$FAILURE" + fi + + return "$SUCCESS" +} + +# Test 2: Write and read test +test_mount_write_read() { + run_test "Mount, write, unmount, remount, read" + + local test_file="/mnt/$TEST_MOUNT_POINT/test_file.txt" + local test_content="Hello from integration tests!" + + mount_device "$TEST_LV_MAPPER" "$TEST_MOUNT_POINT" "defaults" &>/dev/null + + # Write test file + if echo "$test_content" > "$test_file"; then + log_pass "Successfully wrote test file" + else + log_fail "Failed to write test file" + return "$FAILURE" + fi + + unmount_device "$TEST_MOUNT_POINT" &>/dev/null + + # Remount + mount_device "$TEST_LV_MAPPER" "$TEST_MOUNT_POINT" "defaults" &>/dev/null + + # Read and verify + if [[ -f "$test_file" ]]; then + local read_content + read_content=$(cat "$test_file") + if [[ "$read_content" == "$test_content" ]]; then + log_pass "Successfully read test file with correct content" + else + log_fail "Test file content mismatch" + return "$FAILURE" + fi + else + log_fail "Test file does not exist after remount" + return "$FAILURE" + fi + + # Cleanup + rm -f "$test_file" + unmount_device "$TEST_MOUNT_POINT" &>/dev/null + + return "$SUCCESS" +} + +# Test 3: Double mount handling +test_double_mount() { + run_test "Double mount handling" + + # Mount once + mount_device "$TEST_LV_MAPPER" "$TEST_MOUNT_POINT" "defaults" &>/dev/null + + # Try to mount again (library should skip if already mounted) + if mount_device "$TEST_LV_MAPPER" "$TEST_MOUNT_POINT" "defaults" 2>/dev/null; then + log_pass "Double mount handled gracefully" + else + log_fail "Double mount returned error" + unmount_device "$TEST_MOUNT_POINT" &>/dev/null + return "$FAILURE" + fi + + # Cleanup + unmount_device "$TEST_MOUNT_POINT" &>/dev/null + + return "$SUCCESS" +} + +# Test 4: Double unmount handling +test_double_unmount() { + run_test "Double unmount handling" + + # Mount first + mount_device "$TEST_LV_MAPPER" "$TEST_MOUNT_POINT" "defaults" &>/dev/null + + # Unmount once + unmount_device "$TEST_MOUNT_POINT" &>/dev/null + + # Try to unmount again (library should skip if not mounted) + if unmount_device "$TEST_MOUNT_POINT" 2>/dev/null; then + log_pass "Double unmount handled gracefully" + else + log_fail "Double unmount returned error" + return "$FAILURE" + fi + + return "$SUCCESS" +} + +# Test 5: Mount with "none" device handling +test_mount_none_device() { + run_test "Mount with device='none'" + + # Mount with "none" device (library should skip) + if mount_device "none" "test_none" "defaults"; then + log_pass "mount_device with 'none' device handled correctly" + else + log_fail "mount_device with 'none' device returned error" + return "$FAILURE" + fi + + # Verify nothing was actually mounted + if ! mountpoint -q "/mnt/test_none"; then + log_pass "Nothing mounted for 'none' device" + else + log_fail "Something was mounted for 'none' device" + unmount_device "test_none" &>/dev/null + return "$FAILURE" + fi + + return "$SUCCESS" +} + +# Run all tests +main() { + echo "=========================================" + echo "Mount Integration Tests" + echo "=========================================" + echo "" + + if test_mount_unmount; then + pass_test + else + fail_test + fi + + if test_mount_write_read; then + pass_test + else + fail_test + fi + + if test_double_mount; then + pass_test + else + fail_test + fi + + if test_double_unmount; then + pass_test + else + fail_test + fi + + if test_mount_none_device; then + pass_test + else + fail_test + fi + + echo "" + echo "=========================================" + echo "Test Results" + echo "=========================================" + echo "Tests run: $TESTS_RUN" + echo "Tests passed: $TESTS_PASSED" + echo "Tests failed: $TESTS_FAILED" + echo "" + + if [[ $TESTS_FAILED -eq 0 ]]; then + echo -e "${GREEN}All tests passed!${NC}" + exit 0 + else + echo -e "${RED}Some tests failed!${NC}" + exit 1 + fi +} + +main "$@" diff --git a/tests/run-tests.sh b/tests/run-tests.sh new file mode 100755 index 0000000..6d1f478 --- /dev/null +++ b/tests/run-tests.sh @@ -0,0 +1,223 @@ +#!/bin/bash +# Main test runner script +# Runs integration tests (with root) and/or E2E tests +# +# Usage: +# ./run-tests.sh # Run all tests (integration + E2E) +# ./run-tests.sh --integration-only # Run only integration tests (requires root) +# ./run-tests.sh --e2e-only # Run only E2E tests +# +# Note: This script is designed to run inside a VM (via tests/vm/run-tests.sh). +# For local development, use: +# - Unit tests: bats tests/unit/*.bats +# - E2E CLI tests (no root): ./tests/e2e/test-e2e.sh + +set -euo pipefail + +# Script directory +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Colors +readonly RED='\033[0;31m' +readonly GREEN='\033[0;32m' +readonly BLUE='\033[0;34m' +readonly NC='\033[0m' + +# Test results +PHASE_PASSED=() +PHASE_FAILED=() + +log_info() { + echo -e "${BLUE}[INFO]${NC} $*" +} + +log_success() { + echo -e "${GREEN}[SUCCESS]${NC} $*" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $*" +} + +log_phase() { + echo "" + echo "=========================================" + echo "$*" + echo "=========================================" + echo "" +} + +# Integration Tests (requires root, runs in VM) +run_integration_tests() { + log_phase "Integration Tests (requires root)" + + if [[ $EUID -ne 0 ]]; then + log_error "Integration tests require root privileges" + log_info "Run with: sudo $0 --integration-only" + PHASE_FAILED+=("Integration Tests (root required)") + return 1 + fi + + # Setup test environment + log_info "Setting up test environment..." + if ! "$SCRIPT_DIR/fixtures/setup-test-env.sh"; then + log_error "Failed to setup test environment" + log_info "Running cleanup after failed setup..." + "$SCRIPT_DIR/fixtures/cleanup-test-env.sh" || true + PHASE_FAILED+=("Integration Tests (setup failed)") + return 1 + fi + + local test_files=( + "$SCRIPT_DIR/integration/test-luks.sh" + "$SCRIPT_DIR/integration/test-lvm.sh" + "$SCRIPT_DIR/integration/test-mount.sh" + ) + + local failed=0 + + for test_file in "${test_files[@]}"; do + log_info "Running: $(basename "$test_file")" + if "$test_file" 2>&1; then + log_success "✓ $(basename "$test_file") passed" + else + local exit_code=$? + log_error "✗ $(basename "$test_file") failed (exit code: $exit_code)" + failed=1 + fi + done + + # Cleanup test environment + log_info "Cleaning up test environment..." + "$SCRIPT_DIR/fixtures/cleanup-test-env.sh" + + if [[ $failed -eq 0 ]]; then + PHASE_PASSED+=("Integration Tests") + return 0 + else + PHASE_FAILED+=("Integration Tests") + return 1 + fi +} + +# End-to-End Tests +run_e2e_tests() { + log_phase "End-to-End Tests" + + local test_file="$SCRIPT_DIR/e2e/test-e2e.sh" + + if [[ ! -f "$test_file" ]]; then + log_error "E2E test file not found: $test_file" + PHASE_FAILED+=("E2E Tests (file not found)") + return 1 + fi + + log_info "Running: $(basename "$test_file")" + if "$test_file"; then + log_success "✓ E2E tests passed" + PHASE_PASSED+=("End-to-End Tests") + return 0 + else + log_error "✗ E2E tests failed" + PHASE_FAILED+=("End-to-End Tests") + return 1 + fi +} + +# Print summary +print_summary() { + echo "" + echo "=========================================" + echo "TEST SUMMARY" + echo "=========================================" + echo "" + + if [[ ${#PHASE_PASSED[@]} -gt 0 ]]; then + echo -e "${GREEN}Passed:${NC}" + for phase in "${PHASE_PASSED[@]}"; do + echo -e " ${GREEN}✓${NC} $phase" + done + echo "" + fi + + if [[ ${#PHASE_FAILED[@]} -gt 0 ]]; then + echo -e "${RED}Failed:${NC}" + for phase in "${PHASE_FAILED[@]}"; do + echo -e " ${RED}✗${NC} $phase" + done + echo "" + fi + + local total_phases=$((${#PHASE_PASSED[@]} + ${#PHASE_FAILED[@]})) + echo "Results: ${#PHASE_PASSED[@]}/$total_phases phases passed" + echo "" +} + +show_usage() { + cat << EOF +Usage: $(basename "$0") [OPTIONS] + +Run srv-ctl test suites. + +Options: + --integration-only Run only integration tests (requires root) + --e2e-only Run only E2E tests + -h, --help Show this help message + +Note: This script is designed to run inside a VM (via tests/vm/run-tests.sh). +For local development, use: + - Unit tests: bats tests/unit/*.bats + - Lint: shellcheck -x srv-ctl.sh lib/*.sh + +EOF +} + +# Main +main() { + local run_e2e=true + local run_integration=true + + # Parse arguments + while [[ $# -gt 0 ]]; do + case "$1" in + --integration-only) + run_e2e=false + shift + ;; + --e2e-only) + run_integration=false + shift + ;; + -h|--help) + show_usage + exit 0 + ;; + *) + log_error "Unknown option: $1" + show_usage + exit 1 + ;; + esac + done + + log_info "Starting test suite..." + + if $run_integration; then + run_integration_tests || true + fi + + if $run_e2e; then + run_e2e_tests || true + fi + + print_summary + + # Exit with error if any phase failed + if [[ ${#PHASE_FAILED[@]} -gt 0 ]]; then + exit 1 + else + exit 0 + fi +} + +main "$@" diff --git a/tests/unit/test-os-utils.bats b/tests/unit/test-os-utils.bats new file mode 100644 index 0000000..0eb39bc --- /dev/null +++ b/tests/unit/test-os-utils.bats @@ -0,0 +1,86 @@ +#!/usr/bin/env bats +# Unit tests for lib/os-utils.sh + +# Setup test environment +setup() { + # Load the library under test + export SUCCESS=0 + export FAILURE=1 + source "${BATS_TEST_DIRNAME}/../../lib/os-utils.sh" +} + +# Test get_uid_from_username() +@test "get_uid_from_username returns UID for valid username" { + # Use 'root' as a reliable test user present on all systems + run get_uid_from_username "root" + [ "$status" -eq 0 ] + [ "$output" = "0" ] +} + +@test "get_uid_from_username returns error for invalid username" { + run get_uid_from_username "nonexistent_user_12345" + [ "$status" -eq 1 ] + [[ "$output" =~ "ERROR" ]] +} + +@test "get_uid_from_username handles empty username" { + run get_uid_from_username "" + [ "$status" -eq 1 ] + [[ "$output" =~ "ERROR" ]] +} + +# Test get_gid_from_groupname() +@test "get_gid_from_groupname returns GID for valid groupname" { + # Use 'root' as a reliable test group present on all systems + run get_gid_from_groupname "root" + [ "$status" -eq 0 ] + [ "$output" = "0" ] +} + +@test "get_gid_from_groupname returns error for invalid groupname" { + run get_gid_from_groupname "nonexistent_group_12345" + [ "$status" -eq 1 ] + [[ "$output" =~ "ERROR" ]] +} + +@test "get_gid_from_groupname handles empty groupname" { + run get_gid_from_groupname "" + [ "$status" -eq 1 ] + [[ "$output" =~ "ERROR" ]] +} + +# Test build_mount_options() +@test "build_mount_options creates correct options for root user" { + run build_mount_options "root" "root" "defaults" + [ "$status" -eq 0 ] + [ "$output" = "uid=0,gid=0" ] +} + +@test "build_mount_options returns error for invalid username" { + run build_mount_options "nonexistent_user_12345" "root" + [ "$status" -eq 1 ] +} + +@test "build_mount_options returns error for invalid groupname" { + run build_mount_options "root" "nonexistent_group_12345" + [ "$status" -eq 1 ] +} + +@test "build_mount_options handles both invalid username and groupname" { + run build_mount_options "nonexistent_user_12345" "nonexistent_group_12345" + [ "$status" -eq 1 ] +} + +# Test start_service() and stop_service() +# Note: These tests are limited as they would require systemd and privileges +@test "start_service requires service name argument" { + # This is a basic syntax test - full testing requires systemd + run bash -c 'source lib/os-utils.sh; declare -F start_service' + [ "$status" -eq 0 ] +} + +@test "stop_service requires service name argument" { + # This is a basic syntax test - full testing requires systemd + run bash -c 'source lib/os-utils.sh; declare -F stop_service' + [ "$status" -eq 0 ] +} diff --git a/tests/unit/test-storage.bats b/tests/unit/test-storage.bats new file mode 100644 index 0000000..53f33fb --- /dev/null +++ b/tests/unit/test-storage.bats @@ -0,0 +1,67 @@ +#!/usr/bin/env bats +# Unit tests for lib/storage.sh + +# Setup test environment +setup() { + # Load the libraries under test + export SUCCESS=0 + export FAILURE=1 + source "${BATS_TEST_DIRNAME}/../../lib/os-utils.sh" + source "${BATS_TEST_DIRNAME}/../../lib/storage.sh" +} + +# Test function declarations (smoke tests) +@test "wait_for_device function exists" { + run bash -c 'source lib/storage.sh; declare -F wait_for_device' + [ "$status" -eq 0 ] +} + +@test "verify_lvm function exists" { + run bash -c 'source lib/storage.sh; declare -F verify_lvm' + [ "$status" -eq 0 ] +} + +@test "lvm_is_active function exists" { + run bash -c 'source lib/storage.sh; declare -F lvm_is_active' + [ "$status" -eq 0 ] +} + +@test "activate_lvm function exists" { + run bash -c 'source lib/storage.sh; declare -F activate_lvm' + [ "$status" -eq 0 ] +} + +@test "deactivate_lvm function exists" { + run bash -c 'source lib/storage.sh; declare -F deactivate_lvm' + [ "$status" -eq 0 ] +} + +@test "unlock_device function exists" { + run bash -c 'source lib/storage.sh; declare -F unlock_device' + [ "$status" -eq 0 ] +} + +@test "lock_device function exists" { + run bash -c 'source lib/storage.sh; declare -F lock_device' + [ "$status" -eq 0 ] +} + +@test "mount_device function exists" { + run bash -c 'source lib/storage.sh; declare -F mount_device' + [ "$status" -eq 0 ] +} + +@test "unmount_device function exists" { + run bash -c 'source lib/storage.sh; declare -F unmount_device' + [ "$status" -eq 0 ] +} + +@test "mount_network_path function exists" { + run bash -c 'source lib/storage.sh; declare -F mount_network_path' + [ "$status" -eq 0 ] +} + +# Behavioral testing for storage.sh functions requires root and real devices. +# These smoke tests verify API stability - if a function is renamed or removed, +# these tests will catch the breaking change. Full behavioral coverage is in +# tests/integration/ (run via VM for isolation). diff --git a/tests/vm/cleanup.sh b/tests/vm/cleanup.sh new file mode 100755 index 0000000..b5ba18d --- /dev/null +++ b/tests/vm/cleanup.sh @@ -0,0 +1,17 @@ +#!/bin/bash +# Cleanup VM test artifacts + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Kill any running QEMU VMs for srv-ctl tests +pkill -f "srv-ctl-test" || true + +# Clean up result directories +rm -rf "$SCRIPT_DIR/results" + +# Clean up temporary work directories +rm -rf /tmp/srv-ctl-vm-* + +echo "VM test cleanup complete" diff --git a/tests/vm/download-image.sh b/tests/vm/download-image.sh new file mode 100755 index 0000000..c922c06 --- /dev/null +++ b/tests/vm/download-image.sh @@ -0,0 +1,40 @@ +#!/bin/bash +# Download cloud images for VM testing +# Supports Ubuntu and Debian + +set -euo pipefail + +OS_VERSION="$1" +CACHE_DIR="${HOME}/.cache/vm-images" +mkdir -p "$CACHE_DIR" + +# Image URLs +declare -A IMAGE_URLS=( + ["ubuntu-18.04"]="https://cloud-images.ubuntu.com/releases/18.04/release/ubuntu-18.04-server-cloudimg-amd64.img" + ["ubuntu-20.04"]="https://cloud-images.ubuntu.com/releases/20.04/release/ubuntu-20.04-server-cloudimg-amd64.img" + ["ubuntu-22.04"]="https://cloud-images.ubuntu.com/releases/22.04/release/ubuntu-22.04-server-cloudimg-amd64.img" + ["ubuntu-24.04"]="https://cloud-images.ubuntu.com/releases/24.04/release/ubuntu-24.04-server-cloudimg-amd64.img" + ["debian-10"]="https://cloud.debian.org/images/cloud/buster/latest/debian-10-generic-amd64.qcow2" + ["debian-11"]="https://cloud.debian.org/images/cloud/bullseye/latest/debian-11-generic-amd64.qcow2" + ["debian-12"]="https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-generic-amd64.qcow2" + ["debian-13"]="https://cloud.debian.org/images/cloud/trixie/daily/latest/debian-13-generic-amd64-daily.qcow2" +) + +if [[ ! -v IMAGE_URLS["$OS_VERSION"] ]]; then + echo "ERROR: Unsupported OS version: $OS_VERSION" + echo "Supported: ${!IMAGE_URLS[@]}" + exit 1 +fi + +IMAGE_URL="${IMAGE_URLS[$OS_VERSION]}" +IMAGE_FILE="$CACHE_DIR/${OS_VERSION}.qcow2" + +if [[ -f "$IMAGE_FILE" ]]; then + echo "Image already cached: $IMAGE_FILE" + exit 0 +fi + +echo "Downloading $OS_VERSION cloud image..." +curl -L -o "$IMAGE_FILE.tmp" "$IMAGE_URL" +mv "$IMAGE_FILE.tmp" "$IMAGE_FILE" +echo "Downloaded to: $IMAGE_FILE" diff --git a/tests/vm/run-e2e-tests.sh b/tests/vm/run-e2e-tests.sh new file mode 100755 index 0000000..01e06ac --- /dev/null +++ b/tests/vm/run-e2e-tests.sh @@ -0,0 +1,112 @@ +#!/bin/bash +# Run E2E tests in a QEMU VM +# Provides complete isolation with full systemd, network stack, etc. + +set -euo pipefail + +OS_VERSION="${1:-ubuntu-22.04}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +export OS_VERSION SCRIPT_DIR PROJECT_ROOT + +# Source common VM functions +source "$SCRIPT_DIR/vm-common.sh" + +# Override test command for E2E tests +run_tests_in_vm() { + local work_dir="$1" + local ssh_key="$work_dir/id_rsa" + + log_step "Copying project to VM..." + + # Create tarball + local tar_file="$work_dir/srv-ctl.tar.gz" + (cd "$PROJECT_ROOT" && tar -czf "$tar_file" \ + --exclude='.git' \ + --exclude='*.tar.gz' \ + --exclude='tests/vm/work' \ + --exclude='tests/vm/results' \ + .) + + # Copy to VM + scp -i "$ssh_key" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ + -P 2222 "$tar_file" testuser@localhost:/tmp/srv-ctl.tar.gz + + log_step "Running E2E tests in VM..." + + # Run tests via SSH + ssh -i "$ssh_key" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ + -p 2222 testuser@localhost << 'EOSSH' +set -euo pipefail + +# Wait for cloud-init to complete +echo "Waiting for cloud-init to finish..." +cloud-init status --wait || true + +# Extract project +mkdir -p /tmp/srv-ctl-test +cd /tmp/srv-ctl-test +tar -xzf /tmp/srv-ctl.tar.gz + +# Setup test config +cp tests/fixtures/config.local.test config.local + +# Make scripts executable +chmod +x srv-ctl.sh +chmod +x tests/run-tests.sh +chmod +x tests/e2e/*.sh +chmod +x tests/fixtures/*.sh + +# Run E2E tests only +echo "=========================================" +echo "Running E2E tests in VM" +echo "OS: $(lsb_release -ds)" +echo "Kernel: $(uname -r)" +echo "=========================================" +echo "" + +sudo ./tests/run-tests.sh --e2e-only + +EOSSH + + local exit_code=$? + + # Copy results back + mkdir -p "$RESULTS_DIR" + scp -i "$ssh_key" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ + -P 2222 -r testuser@localhost:/tmp/test-results/* "$RESULTS_DIR/" 2>/dev/null || true + + return $exit_code +} + +# Main +main() { + log_info "E2E test runner for srv-ctl" + log_info "OS: $OS_VERSION" + echo "" + + check_prerequisites + + # Create temporary work directory + local work_dir + work_dir=$(mktemp -d -t srv-ctl-vm-XXXXXX) + trap 'cleanup_vm "$work_dir"' EXIT INT TERM + + log_step "Setting up VM environment in: $work_dir" + + create_cloud_init "$work_dir" + create_vm_disk "$work_dir" + start_vm "$work_dir" + + if run_tests_in_vm "$work_dir"; then + echo "" + log_info "✓ All E2E tests passed for $OS_VERSION" + exit 0 + else + echo "" + log_error "✗ Some E2E tests failed for $OS_VERSION" + exit 1 + fi +} + +main "$@" diff --git a/tests/vm/run-tests.sh b/tests/vm/run-tests.sh new file mode 100755 index 0000000..cc24961 --- /dev/null +++ b/tests/vm/run-tests.sh @@ -0,0 +1,302 @@ +#!/bin/bash +# Run integration tests in a QEMU VM +# Provides complete isolation with full systemd, network stack, etc. + +set -euo pipefail + +OS_VERSION="${1:-ubuntu-22.04}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +CACHE_DIR="${HOME}/.cache/vm-images" +RESULTS_DIR="$SCRIPT_DIR/results" + +# Colors +readonly GREEN='\033[0;32m' +readonly YELLOW='\033[1;33m' +readonly RED='\033[0;31m' +readonly NC='\033[0m' + +log_info() { + echo -e "${GREEN}[INFO]${NC} $*" +} + +log_step() { + echo -e "${YELLOW}[STEP]${NC} $*" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $*" +} + +# Check prerequisites +check_prerequisites() { + local missing=() + + command -v qemu-system-x86_64 &>/dev/null || missing+=("qemu-system-x86_64") + command -v qemu-img &>/dev/null || missing+=("qemu-img") + command -v cloud-localds &>/dev/null || missing+=("cloud-localds (cloud-image-utils)") + + if [[ ${#missing[@]} -gt 0 ]]; then + log_error "Missing dependencies: ${missing[*]}" + echo "Install with: sudo apt-get install qemu-system-x86 qemu-utils cloud-image-utils" + exit 1 + fi +} + +# Create cloud-init configuration +create_cloud_init() { + local work_dir="$1" + + # Generate SSH key if it doesn't exist + local ssh_key="$work_dir/id_rsa" + if [[ ! -f "$ssh_key" ]]; then + ssh-keygen -t rsa -b 2048 -f "$ssh_key" -N "" -C "srv-ctl-test" &>/dev/null + fi + local ssh_pub_key + ssh_pub_key=$(cat "$ssh_key.pub") + + cat > "$work_dir/user-data" << EOF +#cloud-config +users: + - name: testuser + sudo: ALL=(ALL) NOPASSWD:ALL + shell: /bin/bash + lock_passwd: true + ssh_authorized_keys: + - $ssh_pub_key + +packages: + - cryptsetup + - lvm2 + - dosfstools + - ntfs-3g + - exfat-fuse + - exfatprogs + - cifs-utils + - nfs-common + - curl + +runcmd: + - systemctl restart sshd + +write_files: + - path: /etc/ssh/sshd_config.d/test.conf + content: | + PermitRootLogin yes + PubkeyAuthentication yes + permissions: '0644' + +final_message: "VM ready for testing" +EOF + + cat > "$work_dir/meta-data" << EOF +instance-id: srv-ctl-test-${OS_VERSION} +local-hostname: srv-ctl-test +EOF + + cloud-localds "$work_dir/cloud-init.img" "$work_dir/user-data" "$work_dir/meta-data" +} + +# Create test VM disk +create_vm_disk() { + local work_dir="$1" + local base_image="$CACHE_DIR/${OS_VERSION}.qcow2" + + if [[ ! -f "$base_image" ]]; then + log_error "Base image not found: $base_image" + log_info "Run: ./tests/vm/download-image.sh $OS_VERSION" + exit 1 + fi + + # Create overlay disk (doesn't modify base image) + qemu-img create -f qcow2 -b "$base_image" -F qcow2 "$work_dir/disk.qcow2" 20G + + # Create additional disk for storage tests + qemu-img create -f qcow2 "$work_dir/test-disk.qcow2" 500M +} + +# Start VM +start_vm() { + local work_dir="$1" + + log_step "Starting VM..." + + # Detect if KVM is available + local accel="tcg" + local cpu_type="qemu64" + local max_wait=120 + if [[ -r /dev/kvm ]] && [[ -w /dev/kvm ]]; then + accel="kvm" + cpu_type="host" + log_info "Using KVM acceleration" + else + log_info "KVM not available, using TCG (software emulation)" + max_wait=300 # TCG is much slower, need more time + fi + + qemu-system-x86_64 \ + -name "srv-ctl-test-${OS_VERSION}" \ + -machine type=q35,accel=$accel \ + -cpu $cpu_type \ + -m 2048 \ + -smp 2 \ + -drive file="$work_dir/disk.qcow2",if=virtio,format=qcow2 \ + -drive file="$work_dir/test-disk.qcow2",if=virtio,format=qcow2 \ + -drive file="$work_dir/cloud-init.img",if=virtio,format=raw \ + -netdev user,id=net0,hostfwd=tcp::2222-:22 \ + -device virtio-net-pci,netdev=net0 \ + -display none \ + -pidfile "$work_dir/qemu.pid" \ + -daemonize + + # Wait for SSH to be available + log_info "Waiting for VM to boot..." + local waited=0 + local ssh_key="$work_dir/id_rsa" + + while ! ssh -i "$ssh_key" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ + -p 2222 testuser@localhost "echo VM ready" &>/dev/null; do + sleep 2 + waited=$((waited + 2)) + if [[ $waited -ge $max_wait ]]; then + log_error "VM failed to boot within ${max_wait}s" + return 1 + fi + done + + log_info "VM booted successfully" +} + +# Copy project to VM and run tests +run_tests_in_vm() { + local work_dir="$1" + + log_step "Copying project to VM..." + + # Create tarball of project + tar -czf "$work_dir/srv-ctl.tar.gz" -C "$PROJECT_ROOT" \ + --exclude='.git' \ + --exclude='*.qcow2' \ + --exclude='results' \ + . + + # Copy to VM + local ssh_key="$work_dir/id_rsa" + scp -i "$ssh_key" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ + -P 2222 "$work_dir/srv-ctl.tar.gz" testuser@localhost:/tmp/ + + log_step "Running tests in VM..." + + # Run tests via SSH + ssh -i "$ssh_key" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ + -p 2222 testuser@localhost bash << 'EOSSH' +set -euo pipefail + +# Wait for cloud-init to complete +echo "Waiting for cloud-init to finish..." +cloud-init status --wait || true + +# Extract project to a subdirectory +mkdir -p /tmp/srv-ctl-test +cd /tmp/srv-ctl-test +tar -xzf /tmp/srv-ctl.tar.gz + +# Setup test config +cp tests/fixtures/config.local.test config.local + +# Make scripts executable +chmod +x srv-ctl.sh +chmod +x tests/run-tests.sh +chmod +x tests/integration/*.sh +chmod +x tests/fixtures/*.sh +chmod +x tests/e2e/*.sh + +# Fix apt sources for Debian 10 (buster) to use archive.debian.org +if grep -qi 'Debian GNU/Linux 10' /etc/os-release; then + echo "[INFO] Rewriting apt sources for Debian 10 (buster) archive..." + sudo sed -i 's|http://deb.debian.org/debian|http://archive.debian.org/debian|g' /etc/apt/sources.list + sudo sed -i 's|http://security.debian.org/debian-security|http://archive.debian.org/debian-security|g' /etc/apt/sources.list + echo 'Acquire::Check-Valid-Until "false";' | sudo tee /etc/apt/apt.conf.d/99no-check-valid-until + sudo apt-get update || true +fi + +# Install bats +cd /tmp +curl -sSL https://github.com/bats-core/bats-core/archive/v1.13.0.tar.gz | tar -xz +cd bats-core-1.13.0 +sudo ./install.sh /usr/local +cd /tmp/srv-ctl-test + +# Run all test phases +echo "=========================================" +echo "Running integration test suite in VM" +echo "OS: $(lsb_release -ds)" +echo "Kernel: $(uname -r)" +echo "=========================================" +echo "" + +sudo ./tests/run-tests.sh --integration-only + +EOSSH + + local exit_code=$? + + # Copy results back + mkdir -p "$RESULTS_DIR" + scp -i "$ssh_key" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ + -P 2222 -r testuser@localhost:/tmp/test-results/* "$RESULTS_DIR/" 2>/dev/null || true + + return $exit_code +} + +# Cleanup VM +# shellcheck disable=SC2329 # cleanup_vm is invoked via trap, not directly +cleanup_vm() { + local work_dir="$1" + + if [[ -f "$work_dir/qemu.pid" ]]; then + local pid + pid=$(cat "$work_dir/qemu.pid") + if kill -0 "$pid" 2>/dev/null; then + log_info "Stopping VM (PID: $pid)..." + kill "$pid" + # Wait for graceful shutdown + sleep 5 + kill -9 "$pid" 2>/dev/null || true + fi + fi + + rm -rf "$work_dir" +} + +# Main +main() { + log_info "VM-based test runner for srv-ctl" + log_info "OS: $OS_VERSION" + echo "" + + check_prerequisites + + # Create temporary work directory + local work_dir + work_dir=$(mktemp -d -t srv-ctl-vm-XXXXXX) + trap 'cleanup_vm "$work_dir"' EXIT INT TERM + + log_step "Setting up VM environment in: $work_dir" + + create_cloud_init "$work_dir" + create_vm_disk "$work_dir" + start_vm "$work_dir" + + if run_tests_in_vm "$work_dir"; then + echo "" + log_info "✓ All VM tests passed for $OS_VERSION" + exit 0 + else + echo "" + log_error "✗ Some VM tests failed for $OS_VERSION" + exit 1 + fi +} + +main "$@" diff --git a/tests/vm/vm-common.sh b/tests/vm/vm-common.sh new file mode 100644 index 0000000..c1c9b37 --- /dev/null +++ b/tests/vm/vm-common.sh @@ -0,0 +1,185 @@ +#!/bin/bash +# Common VM functions for test runners + +# Set by parent script +: "${OS_VERSION:?}" +: "${SCRIPT_DIR:?}" +: "${PROJECT_ROOT:=$(cd "$SCRIPT_DIR/../.." && pwd)}" + +CACHE_DIR="${HOME}/.cache/vm-images" +RESULTS_DIR="$SCRIPT_DIR/results" + +# Colors +readonly GREEN='\033[0;32m' +readonly YELLOW='\033[1;33m' +readonly RED='\033[0;31m' +readonly NC='\033[0m' + +log_info() { + echo -e "${GREEN}[INFO]${NC} $*" +} + +log_step() { + echo -e "${YELLOW}[STEP]${NC} $*" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $*" +} + +# Check prerequisites +check_prerequisites() { + local missing=() + + command -v qemu-system-x86_64 &>/dev/null || missing+=("qemu-system-x86_64") + command -v qemu-img &>/dev/null || missing+=("qemu-img") + command -v cloud-localds &>/dev/null || missing+=("cloud-localds (cloud-image-utils)") + + if [[ ${#missing[@]} -gt 0 ]]; then + log_error "Missing dependencies: ${missing[*]}" + echo "Install with: sudo apt-get install qemu-system-x86 qemu-utils cloud-image-utils" + exit 1 + fi +} + +# Create cloud-init configuration +create_cloud_init() { + local work_dir="$1" + + # Generate SSH key if it doesn't exist + local ssh_key="$work_dir/id_rsa" + if [[ ! -f "$ssh_key" ]]; then + ssh-keygen -t rsa -b 2048 -f "$ssh_key" -N "" -C "srv-ctl-test" &>/dev/null + fi + local ssh_pub_key + ssh_pub_key=$(cat "$ssh_key.pub") + + cat > "$work_dir/user-data" << EOF +#cloud-config +users: + - name: testuser + sudo: ALL=(ALL) NOPASSWD:ALL + shell: /bin/bash + lock_passwd: true + ssh_authorized_keys: + - $ssh_pub_key + +packages: + - cryptsetup + - lvm2 + - dosfstools + - ntfs-3g + - exfat-fuse + - exfatprogs + - cifs-utils + - nfs-common + - curl + +runcmd: + - systemctl restart sshd + +write_files: + - path: /etc/ssh/sshd_config.d/test.conf + content: | + PermitRootLogin yes + PubkeyAuthentication yes + permissions: '0644' + +final_message: "VM ready for testing" +EOF + + cat > "$work_dir/meta-data" << EOF +instance-id: srv-ctl-test-${OS_VERSION} +local-hostname: srv-ctl-test +EOF + + cloud-localds "$work_dir/cloud-init.img" "$work_dir/user-data" "$work_dir/meta-data" +} + +# Create test VM disk +create_vm_disk() { + local work_dir="$1" + local base_image="$CACHE_DIR/${OS_VERSION}.qcow2" + + if [[ ! -f "$base_image" ]]; then + log_error "Base image not found: $base_image" + log_info "Run: ./tests/vm/download-image.sh $OS_VERSION" + exit 1 + fi + + # Create overlay disk (doesn't modify base image) + qemu-img create -f qcow2 -b "$base_image" -F qcow2 "$work_dir/disk.qcow2" 20G + + # Create additional disk for storage tests + qemu-img create -f qcow2 "$work_dir/test-disk.qcow2" 500M +} + +# Start VM +start_vm() { + local work_dir="$1" + + log_step "Starting VM..." + + # Detect if KVM is available + local accel="tcg" + local cpu_type="qemu64" + local max_wait=120 + if [[ -r /dev/kvm ]] && [[ -w /dev/kvm ]]; then + accel="kvm" + cpu_type="host" + log_info "Using KVM acceleration" + else + log_info "KVM not available, using TCG (software emulation)" + max_wait=300 # TCG is much slower, need more time + fi + + qemu-system-x86_64 \ + -name "srv-ctl-test-${OS_VERSION}" \ + -machine type=q35,accel=$accel \ + -cpu $cpu_type \ + -m 2048 \ + -smp 2 \ + -drive file="$work_dir/disk.qcow2",if=virtio,format=qcow2 \ + -drive file="$work_dir/test-disk.qcow2",if=virtio,format=qcow2 \ + -drive file="$work_dir/cloud-init.img",if=virtio,format=raw \ + -netdev user,id=net0,hostfwd=tcp::2222-:22 \ + -device virtio-net-pci,netdev=net0 \ + -display none \ + -pidfile "$work_dir/qemu.pid" \ + -daemonize + + # Wait for SSH to be available + log_info "Waiting for VM to boot..." + local waited=0 + local ssh_key="$work_dir/id_rsa" + + while ! ssh -i "$ssh_key" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ + -p 2222 testuser@localhost "echo VM ready" &>/dev/null; do + sleep 2 + waited=$((waited + 2)) + if [[ $waited -ge $max_wait ]]; then + log_error "VM failed to boot within ${max_wait}s" + return 1 + fi + done + + log_info "VM booted successfully" +} + +# Cleanup VM +cleanup_vm() { + local work_dir="$1" + + if [[ -f "$work_dir/qemu.pid" ]]; then + local pid=$(cat "$work_dir/qemu.pid") + if kill -0 "$pid" 2>/dev/null; then + log_info "Stopping VM (PID: $pid)..." + kill "$pid" + # Wait for graceful shutdown + sleep 5 + kill -9 "$pid" 2>/dev/null || true + fi + fi + + rm -rf "$work_dir" +}