diff --git a/.github/workflows/test-appimage-native.yml b/.github/workflows/test-appimage-native.yml new file mode 100644 index 00000000..8f80cee4 --- /dev/null +++ b/.github/workflows/test-appimage-native.yml @@ -0,0 +1,69 @@ +name: Native Monitor AppImage validation + +on: + pull_request: + paths: + - '.github/workflows/test-appimage-native.yml' + - 'AppImage/scripts/build_appimage.sh' + - 'AppImage/scripts/appimage_arch.sh' + - 'AppImage/scripts/AppRun' + - 'tests/test_appimage_arch.py' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: native-appimage-${{ github.ref }} + cancel-in-progress: true + +jobs: + native-build: + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-22.04 + arch: x86_64 + - runner: ubuntu-22.04-arm + arch: aarch64 + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Validate native architecture selection + run: | + test "$(uname -m)" = '${{ matrix.arch }}' + python -B -m unittest discover -s tests -p test_appimage_arch.py -v + - name: Refresh package indexes for bundled monitoring tools + run: sudo apt-get update + - name: Build native AppImage without publishing it + run: bash AppImage/scripts/build_appimage.sh + - name: Extract native AppImage without FUSE + run: | + mkdir /tmp/proxmenux-native-extract + cd /tmp/proxmenux-native-extract + image=$(find "$GITHUB_WORKSPACE/AppImage/dist" -name '*.AppImage' -type f -print -quit) + test -n "$image" + chmod +x "$image" + "$image" --appimage-extract + test -f squashfs-root/usr/bin/flask_server.py + test -f squashfs-root/usr/bin/appimage_arch.sh + test -f squashfs-root/web/index.html + - name: Import bundled Python 3.11 native WebSocket runtime + env: + PYTHONPATH: /tmp/proxmenux-native-extract/squashfs-root/usr/lib/python3/vendor/cp311:/tmp/proxmenux-native-extract/squashfs-root/usr/lib/python3/dist-packages + run: python -c 'import gevent, greenlet, geventwebsocket, zope.interface; from PIL import Image; from gevent import pywsgi; print(gevent.__version__)' + - uses: actions/setup-python@v5 + with: + python-version: '3.13' + - name: Import bundled Python 3.13 native WebSocket runtime + env: + PYTHONPATH: /tmp/proxmenux-native-extract/squashfs-root/usr/lib/python3/vendor/cp313:/tmp/proxmenux-native-extract/squashfs-root/usr/lib/python3/dist-packages + run: python -c 'import gevent, greenlet, geventwebsocket, zope.interface; from PIL import Image; from gevent import pywsgi; print(gevent.__version__)' diff --git a/AppImage/scripts/AppRun b/AppImage/scripts/AppRun index 74ad4d22..db6e2b6b 100644 --- a/AppImage/scripts/AppRun +++ b/AppImage/scripts/AppRun @@ -6,8 +6,11 @@ # Get the directory where this AppImage is mounted APPDIR="$(dirname "$(readlink -f "${0}")")" +source "${APPDIR}/usr/bin/appimage_arch.sh" || exit 1 +pmx_appimage_arch "$(uname -m)" || exit 1 + export PATH="${APPDIR}/usr/bin:${PATH}" -export LD_LIBRARY_PATH="${APPDIR}/usr/lib/x86_64-linux-gnu:${APPDIR}/usr/lib:${APPDIR}/lib/x86_64-linux-gnu:${APPDIR}/lib:${LD_LIBRARY_PATH}" +export LD_LIBRARY_PATH="${APPDIR}/usr/lib/${PMX_APPIMAGE_TRIPLET}:${APPDIR}/usr/lib:${APPDIR}/lib/${PMX_APPIMAGE_TRIPLET}:${APPDIR}/lib:${LD_LIBRARY_PATH}" # Select the native dependency tree matching the host interpreter. PVE 8 # uses CPython 3.11 and PVE 9 uses CPython 3.13; both runtimes are bundled in diff --git a/AppImage/scripts/appimage_arch.sh b/AppImage/scripts/appimage_arch.sh new file mode 100644 index 00000000..ef7b9f84 --- /dev/null +++ b/AppImage/scripts/appimage_arch.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# Shared native build/runtime architecture names. No emulated cross-builds. +pmx_appimage_arch() { + case "$1" in + x86_64|amd64) + PMX_APPIMAGE_ARCH=x86_64 + PMX_APPIMAGE_TRIPLET=x86_64-linux-gnu + PMX_APPIMAGE_SUFFIX="" + ;; + aarch64|arm64) + PMX_APPIMAGE_ARCH=aarch64 + PMX_APPIMAGE_TRIPLET=aarch64-linux-gnu + PMX_APPIMAGE_SUFFIX=-aarch64 + ;; + *) + echo "Unsupported Monitor AppImage architecture: $1" >&2 + return 1 + ;; + esac +} diff --git a/AppImage/scripts/build_appimage.sh b/AppImage/scripts/build_appimage.sh index 3ccf3d66..ecc7ae0c 100755 --- a/AppImage/scripts/build_appimage.sh +++ b/AppImage/scripts/build_appimage.sh @@ -8,6 +8,8 @@ set -e WORK_DIR="/tmp/proxmenux_build" APP_DIR="$WORK_DIR/ProxMenux.AppDir" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/appimage_arch.sh" +pmx_appimage_arch "$(uname -m)" || exit 1 DIST_DIR="$SCRIPT_DIR/../dist" APPIMAGE_ROOT="$SCRIPT_DIR/.." @@ -24,7 +26,7 @@ ZOPE_EVENT_VERSION="6.2" GEVENT_WEBSOCKET_VERSION="0.10.1" VERSION=$(node -p "require('$APPIMAGE_ROOT/package.json').version") -APPIMAGE_NAME="ProxMenux-${VERSION}.AppImage" +APPIMAGE_NAME="ProxMenux-${VERSION}${PMX_APPIMAGE_SUFFIX}.AppImage" echo "🚀 Building ProxMenux Monitor AppImage v${VERSION} with hardware monitoring tools..." @@ -33,16 +35,9 @@ echo "🚀 Building ProxMenux Monitor AppImage v${VERSION} with hardware monitor # under the non-root GitHub Actions runner — previously hardcoded to # /var/cache/, which the CI user can't write to (mkdir Permission # denied → build aborted before doing any actual work). -APPIMAGETOOL_CACHE="${XDG_CACHE_HOME:-$HOME/.cache}/proxmenux-build/appimagetool" +APPIMAGETOOL_CACHE="${XDG_CACHE_HOME:-$HOME/.cache}/proxmenux-build/appimagetool-${PMX_APPIMAGE_ARCH}" -# Preserve a cached copy of appimagetool across builds. wget -q has bitten -# us repeatedly when GitHub momentarily rate-limits or the runner has no -# network — the result is a 0-byte file that passes the `[ -f ]` check on -# the next run and breaks the build silently. -if [ -f "$WORK_DIR/appimagetool" ] && [ -s "$WORK_DIR/appimagetool" ]; then - mkdir -p "$(dirname "$APPIMAGETOOL_CACHE")" - cp -f "$WORK_DIR/appimagetool" "$APPIMAGETOOL_CACHE" -fi +# Architecture-specific caches never reuse an executable from a different CPU. # Clean and create work directory rm -rf "$WORK_DIR" @@ -57,7 +52,7 @@ if [ -s "$APPIMAGETOOL_CACHE" ]; then fi if [ ! -s "$WORK_DIR/appimagetool" ]; then echo "📥 Downloading appimagetool..." - wget --tries=3 --timeout=60 "https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage" -O "$WORK_DIR/appimagetool" || true + wget --tries=3 --timeout=60 "https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-${PMX_APPIMAGE_ARCH}.AppImage" -O "$WORK_DIR/appimagetool" || true if [ ! -s "$WORK_DIR/appimagetool" ]; then echo "❌ Failed to download appimagetool" >&2 exit 1 @@ -310,6 +305,7 @@ fi echo "📋 Copying AppRun script..." if [ -f "$SCRIPT_DIR/AppRun" ]; then cp "$SCRIPT_DIR/AppRun" "$APP_DIR/AppRun" + cp "$SCRIPT_DIR/appimage_arch.sh" "$APP_DIR/usr/bin/appimage_arch.sh" chmod +x "$APP_DIR/AppRun" echo "✅ AppRun script copied successfully" else @@ -379,7 +375,7 @@ pip3 install --target "$APP_DIR/usr/lib/python3/dist-packages" --upgrade \ # Phase 3b: Install the complete gevent/WSS runtime separately for every # supported host ABI. Keeping each dependency tree isolated avoids mixing # CPython-specific modules from the build runner with those loaded by PVE. -# The two --platform values allow gevent's manylinux_2_28 wheels and the +# The three --platform values allow gevent's manylinux_2_28 wheels and the # backwards-compatible manylinux2014 zope.interface wheels to be resolved in # one deterministic installation. install_gevent_runtime() { @@ -394,9 +390,9 @@ install_gevent_runtime() { --target "$target" \ --upgrade \ --only-binary=:all: \ - --platform manylinux_2_28_x86_64 \ - --platform manylinux_2_27_x86_64 \ - --platform manylinux2014_x86_64 \ + --platform "manylinux_2_28_${PMX_APPIMAGE_ARCH}" \ + --platform "manylinux_2_27_${PMX_APPIMAGE_ARCH}" \ + --platform "manylinux2014_${PMX_APPIMAGE_ARCH}" \ --implementation cp \ --python-version "$python_version" \ --abi "$abi" \ @@ -547,7 +543,7 @@ if [ -d "$APP_DIR/bin" ]; then fi echo "🔍 Sanity check (ldd + presence of libfreeipmi)" -export LD_LIBRARY_PATH="$APP_DIR/lib:$APP_DIR/lib/x86_64-linux-gnu:$APP_DIR/usr/lib:$APP_DIR/usr/lib/x86_64-linux-gnu" +export LD_LIBRARY_PATH="$APP_DIR/lib:$APP_DIR/lib/${PMX_APPIMAGE_TRIPLET}:$APP_DIR/usr/lib:$APP_DIR/usr/lib/${PMX_APPIMAGE_TRIPLET}" if ! find "$APP_DIR/usr/lib" "$APP_DIR/lib" -maxdepth 3 -name 'libfreeipmi.so.17*' | grep -q .; then echo "❌ libfreeipmi.so.17 not found inside AppDir (ipmitool will fail)" @@ -608,7 +604,7 @@ echo "🔨 Building unified AppImage v${VERSION}..." cd "$WORK_DIR" export NO_CLEANUP=1 export APPIMAGE_EXTRACT_AND_RUN=1 -ARCH=x86_64 ./appimagetool --no-appstream --verbose "$APP_DIR" "$APPIMAGE_NAME" +ARCH="$PMX_APPIMAGE_ARCH" ./appimagetool --no-appstream --verbose "$APP_DIR" "$APPIMAGE_NAME" # Move to dist directory mv "$APPIMAGE_NAME" "$DIST_DIR/" diff --git a/docs/monitor-native-builds.md b/docs/monitor-native-builds.md new file mode 100644 index 00000000..feade22e --- /dev/null +++ b/docs/monitor-native-builds.md @@ -0,0 +1,23 @@ +# Native Monitor AppImage builds + +The builder selects its native architecture using `uname -m`. Supported targets +are x86_64 and aarch64 (ARM64). It selects matching appimagetool executables, +separate download caches, manylinux wheels and GNU library paths. Cross-building +by overriding a target variable is intentionally unsupported: APT packages and +other native Python dependencies must also match the build machine. + +Run `bash AppImage/scripts/build_appimage.sh` on a Linux builder with Node.js, +Python/pip, APT and the usual AppImage build dependencies. AMD64 keeps the existing +`ProxMenux-VERSION.AppImage` name; ARM64 produces +`ProxMenux-VERSION-aarch64.AppImage`. + +The native validation workflow builds both architectures on GitHub's native +Ubuntu 22.04 runners, extracts each AppImage without FUSE, and imports its bundled +CPython 3.11 and 3.13 WebSocket/image runtimes. It does not upload artifacts, +change release assets, or modify an installed Monitor. + +This build lane is a prerequisite for ARM distribution, not a declaration of +PXVIRT or Raspberry Pi hardware compatibility. ARM release/checksum publication, +installer selection and validation on the maintainer's PXVIRT lab remain separate +steps before ARM installations can use a published image. The current release +and installer distribution remain unchanged. diff --git a/tests/test_appimage_arch.py b/tests/test_appimage_arch.py new file mode 100644 index 00000000..0678b3a5 --- /dev/null +++ b/tests/test_appimage_arch.py @@ -0,0 +1,77 @@ +"""Exercise native architecture wiring without building or changing the host.""" +import json +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +SCRIPTS = ROOT / 'AppImage/scripts' + + +class AppImageArchitectureTests(unittest.TestCase): + def configure(self, machine): + return subprocess.run(['bash', '-c', + 'source "$1"; pmx_appimage_arch "$2" || exit; ' + 'printf "%s|%s|%s" "$PMX_APPIMAGE_ARCH" "$PMX_APPIMAGE_TRIPLET" "$PMX_APPIMAGE_SUFFIX"', + 'fixture', str(SCRIPTS / 'appimage_arch.sh'), machine], capture_output=True, text=True) + + def test_supported_native_architectures_and_aliases(self): + for machine in ['x86_64', 'amd64']: + self.assertEqual(self.configure(machine).stdout, 'x86_64|x86_64-linux-gnu|') + for machine in ['aarch64', 'arm64']: + self.assertEqual(self.configure(machine).stdout, 'aarch64|aarch64-linux-gnu|-aarch64') + + def test_unsupported_architecture_fails_before_build_side_effects(self): + with tempfile.TemporaryDirectory() as folder: + binary = Path(folder) / 'uname' + binary.write_text('#!/bin/sh\necho loongarch64\n') + binary.chmod(0o755) + result = subprocess.run(['bash', str(SCRIPTS / 'build_appimage.sh')], + env={**os.environ, 'PATH': folder + ':' + os.environ['PATH']}, capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertIn('Unsupported Monitor AppImage architecture: loongarch64', result.stderr) + self.assertNotIn('Building ProxMenux', result.stdout) + + def test_gevent_wheel_installer_uses_native_platform_for_both_abis(self): + source = (SCRIPTS / 'build_appimage.sh').read_text() + function = source[source.index('install_gevent_runtime() {'):source.index('\ninstall_gevent_runtime "3.11"')] + for machine in ['x86_64', 'aarch64']: + with tempfile.TemporaryDirectory() as folder: + command = ('source "$1"; pmx_appimage_arch "$2"; APP_DIR="$3"; ' + 'python3() { printf "%s\\n" "$*"; }; ' + function + + '\ninstall_gevent_runtime 3.11 cp311\ninstall_gevent_runtime 3.13 cp313') + result = subprocess.run(['bash', '-c', command, 'fixture', str(SCRIPTS / 'appimage_arch.sh'), machine, folder], + capture_output=True, text=True, check=True) + for platform in ['manylinux_2_28_', 'manylinux_2_27_', 'manylinux2014_']: + self.assertEqual(result.stdout.count('--platform ' + platform + machine), 2) + for abi in ['cp311', 'cp313']: + self.assertIn('--abi ' + abi, result.stdout) + + def test_apprun_uses_native_library_paths_and_matching_host_python_tree(self): + for machine in ['x86_64', 'aarch64']: + with tempfile.TemporaryDirectory() as folder: + app = Path(folder) + (app / 'usr/bin').mkdir(parents=True) + (app / 'usr/lib/python3/vendor/cp311').mkdir(parents=True) + shutil.copy(SCRIPTS / 'appimage_arch.sh', app / 'usr/bin') + shutil.copy(SCRIPTS / 'AppRun', app / 'AppRun') + for name, body in { + 'uname': 'echo ' + machine, + 'readlink': 'printf "%s\\n" "$2"', + 'python3': 'if [ "$1" = -c ]; then echo cp311; else printf "LIB=%s\\nPY=%s\\n" "$LD_LIBRARY_PATH" "$PYTHONPATH"; fi', + }.items(): + file = app / 'usr/bin' / name + file.write_text('#!/bin/sh\n' + body + '\n') + file.chmod(0o755) + result = subprocess.run(['bash', str(app / 'AppRun'), '--translate'], + env={**os.environ, 'PATH': str(app / 'usr/bin') + ':' + os.environ['PATH']}, + capture_output=True, text=True, check=True) + self.assertIn(str(app / ('usr/lib/' + machine + '-linux-gnu')), result.stdout) + self.assertIn(str(app / 'usr/lib/python3/vendor/cp311'), result.stdout) + + +if __name__ == '__main__': + unittest.main()