From c2ef5a54a6ca598dba548e335a6950d1f08b0a98 Mon Sep 17 00:00:00 2001 From: Jonatan <190036+jcastro@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:57:14 +0200 Subject: [PATCH 1/2] fix: update Figurine and select architecture-specific binaries --- .github/workflows/test-figurine.yml | 73 +++++++ AppImage/scripts/flask_proxmenux_routes.py | 2 +- .../post_install/customizable_post_install.sh | 18 +- tests/test_figurine_installation.py | 204 ++++++++++++++++++ .../docs/post-install/optional/page.tsx | 28 ++- .../en/docs/post-install/optional.json | 4 +- .../es/docs/post-install/optional.json | 4 +- 7 files changed, 314 insertions(+), 19 deletions(-) create mode 100644 .github/workflows/test-figurine.yml create mode 100644 tests/test_figurine_installation.py diff --git a/.github/workflows/test-figurine.yml b/.github/workflows/test-figurine.yml new file mode 100644 index 00000000..b379ce4b --- /dev/null +++ b/.github/workflows/test-figurine.yml @@ -0,0 +1,73 @@ +name: Figurine installation + +on: + pull_request: + paths: + - '.github/workflows/test-figurine.yml' + - 'tests/test_figurine_installation.py' + - 'scripts/post_install/customizable_post_install.sh' + - 'AppImage/scripts/post_install_versions.py' + - 'AppImage/scripts/flask_proxmenux_routes.py' + push: + branches: [main, develop] + paths: + - '.github/workflows/test-figurine.yml' + - 'tests/test_figurine_installation.py' + - 'scripts/post_install/customizable_post_install.sh' + - 'AppImage/scripts/post_install_versions.py' + - 'AppImage/scripts/flask_proxmenux_routes.py' + workflow_dispatch: + +permissions: + contents: read + +jobs: + installer-and-release: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Check shell syntax and isolated installer regressions + run: | + bash -n scripts/post_install/customizable_post_install.sh + python3 -B -m unittest discover -s tests -p test_figurine_installation.py -v + - name: Install ARM user-mode emulation for release smoke tests + run: | + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends qemu-user + - name: Verify and run the pinned AMD64, ARM64 and ARMv7 release binaries + env: + FIGURINE_VERSION: '2.1.1' + shell: bash + run: | + set -euo pipefail + scratch="$(mktemp -d)" + trap 'rm -rf "$scratch"' EXIT + while read -r arch sha; do + archive="figurine_linux_${arch}_v${FIGURINE_VERSION}.tar.gz" + target="$scratch/$arch" + mkdir -p "$target" + curl --fail --location --retry 3 --output "$target/$archive" \ + "https://github.com/arsham/figurine/releases/download/v${FIGURINE_VERSION}/$archive" + printf '%s %s\n' "$sha" "$target/$archive" | sha256sum --check + tar -xf "$target/$archive" -C "$target" + runner=() + case "$arch" in + arm64) runner=(qemu-aarch64) ;; + arm) runner=(qemu-arm) ;; + esac + "${runner[@]}" "$target/deploy/figurine" --version | tee "$target/version" + grep -F "figurine version v$FIGURINE_VERSION" "$target/version" + "${runner[@]}" "$target/deploy/figurine" -f "3d.flf" "ProxMenux-test" > "$target/banner" + test -s "$target/banner" + printf 'PASS: %s version and login banner\n' "$arch" + done <<'ASSETS' + amd64 f2b1c076942c94009d133df0f304a234a0994bf590b40f2927668324d578306a + arm64 611550ddb31a75437be3f8587bcdd029d0d890f4d820b7f494a2c6615fecba10 + arm 429e66bded486d49f730c4a6559e728cd1e9a163a0f56a85867e2cbec010171d + ASSETS diff --git a/AppImage/scripts/flask_proxmenux_routes.py b/AppImage/scripts/flask_proxmenux_routes.py index 48da2fbe..a5fa8266 100644 --- a/AppImage/scripts/flask_proxmenux_routes.py +++ b/AppImage/scripts/flask_proxmenux_routes.py @@ -35,7 +35,7 @@ TOOL_METADATA = { 'system_utils': {'name': 'System Utilities', 'function': 'install_system_utils', 'version': '1.0'}, 'network_optimization': {'name': 'Network Optimizations', 'function': 'apply_network_optimizations', 'version': '1.0'}, 'bashrc_custom': {'name': 'Bashrc Customization', 'function': 'customize_bashrc', 'version': '1.0'}, - 'figurine': {'name': 'Figurine', 'function': 'configure_figurine', 'version': '1.0'}, + 'figurine': {'name': 'Figurine', 'function': 'configure_figurine', 'version': '1.2'}, 'fastfetch': {'name': 'Fastfetch', 'function': 'configure_fastfetch', 'version': '1.0'}, 'log2ram': {'name': 'Log2ram (SSD Protection)', 'function': 'configure_log2ram', 'version': '1.0'}, 'zfs_autotrim': {'name': 'ZFS Autotrim', 'function': 'enable_zfs_autotrim', 'version': '1.0'}, diff --git a/scripts/post_install/customizable_post_install.sh b/scripts/post_install/customizable_post_install.sh index 49ed0f3c..0f0f635d 100644 --- a/scripts/post_install/customizable_post_install.sh +++ b/scripts/post_install/customizable_post_install.sh @@ -3034,15 +3034,27 @@ register_tool "fastfetch" true "$FUNC_VERSION" configure_figurine() { - local FUNC_VERSION="1.1" + local FUNC_VERSION="1.2" pmx_journal_context "configure_figurine" "$FUNC_VERSION" # description: Install Figurine (ASCII-art hostname banner) and wire it into the SSH login flow. msg_info2 "$(translate "Installing and configuring Figurine...")" # `FIGURINE_VERSION` env var allows pinning to a specific release; # default tracks the last tested upstream tag. Audit Tier 6 — # recursos remotos sin pinning de versión. - local version="${FIGURINE_VERSION:-2.0.0}" - local file="figurine_linux_amd64_v${version}.tar.gz" + local version="${FIGURINE_VERSION:-2.1.1}" + # Prefer the Debian userspace ABI; the upstream 32-bit ARM build requires ARMv7. + local arch + arch="$(dpkg --print-architecture 2>/dev/null || uname -m)" + case "$arch" in + amd64|x86_64) arch="amd64" ;; + arm64|aarch64) arch="arm64" ;; + armhf|armv7l|armv8l) arch="arm" ;; + *) + msg_error "$(translate "Unsupported architecture for Figurine:") $arch" + return 1 + ;; + esac + local file="figurine_linux_${arch}_v${version}.tar.gz" local url="https://github.com/arsham/figurine/releases/download/v${version}/${file}" local temp_dir; temp_dir=$(mktemp -d) local install_dir="/usr/local/bin" diff --git a/tests/test_figurine_installation.py b/tests/test_figurine_installation.py new file mode 100644 index 00000000..2e7e7b36 --- /dev/null +++ b/tests/test_figurine_installation.py @@ -0,0 +1,204 @@ +"""Exercise the real Figurine installer with isolated files and no network/root access.""" + +import ast +import importlib.util +import io +from pathlib import Path +import re +import subprocess +import tarfile +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts/post_install/customizable_post_install.sh" +FUNCTION = re.search( + r"^configure_figurine\(\) \{\n.*?^\}", SCRIPT.read_text(), re.M | re.S +).group(0) +VERSION = re.search(r'FIGURINE_VERSION:-([^}]+)', FUNCTION).group(1) +FUNCTION_VERSION = re.search(r'local FUNC_VERSION="([^"]+)"', FUNCTION).group(1) + + +class FigurineInstallation(unittest.TestCase): + def run_installer(self, arch="amd64", *, kernel="x86_64", dpkg_exit=0, + release=None, download_exit=0, package="valid", existing=True): + with tempfile.TemporaryDirectory(prefix="figurine-test-") as directory: + root = Path(directory) + binary_dir = root / "bin" + profile_dir = root / "profile.d" + binary_dir.mkdir() + profile_dir.mkdir() + (root / "tmp").mkdir() + binary = binary_dir / "figurine" + profile = profile_dir / "figurine.sh" + bashrc = root / "bashrc" + if existing: + binary.write_text("previous binary\n") + binary.chmod(0o755) + profile.write_text("previous profile\n") + bashrc.write_text("# previous bashrc\n") + archive = root / "fixture.tar.gz" + payload = b"#!/bin/sh\nprintf 'fixture Figurine\\n'\n" + if package == "corrupt": + archive.write_bytes(b"invalid tarball") + else: + with tarfile.open(archive, "w:gz") as tar: + name = "deploy/figurine" if package == "valid" else "README" + member = tarfile.TarInfo(name) + member.size = len(payload) + tar.addfile(member, io.BytesIO(payload)) + + # Relocate every installer filesystem boundary, including its login script. + function = (FUNCTION.replace("/usr/local/bin", str(binary_dir)) + .replace("/etc/profile.d", str(profile_dir)) + .replace("/root/.bashrc", str(bashrc))) + harness = r''' +translate() { printf '%s' "$1"; } +msg_info2() { :; } +msg_info() { :; } +msg_ok() { :; } +msg_success() { :; } +msg_error() { printf '%s\n' "$*" >&2; } +pmx_journal_context() { :; } +pmx_write_file() { cat > "$1"; } +pmx_append_file() { cat >> "$1"; } +pmx_edit_file() { :; } +chown() { :; } +dpkg() { + [[ "$TEST_DPKG_EXIT" == 0 ]] || return "$TEST_DPKG_EXIT" + printf '%s\n' "$TEST_ARCH" +} +uname() { printf '%s\n' "$TEST_KERNEL"; } +wget() { + [[ "$1" == -qO ]] || return 90 + printf '%s\n' "$3" >> "$TEST_ROOT/downloads" + [[ "$TEST_DOWNLOAD_EXIT" == 0 ]] || return "$TEST_DOWNLOAD_EXIT" + cp "$TEST_ROOT/fixture.tar.gz" "$2" +} +register_tool() { printf '%s\n' "$*" > "$TEST_ROOT/registered"; } +''' + harness += function + "\nconfigure_figurine\nexit \"$?\"\n" + environment = { + "PATH": f"{binary_dir}:/usr/bin:/bin", + "HOME": str(root), + "TMPDIR": str(root / "tmp"), + "LC_ALL": "C", + "TEST_ROOT": str(root), + "TEST_ARCH": arch, + "TEST_DPKG_EXIT": str(dpkg_exit), + "TEST_KERNEL": kernel, + "TEST_DOWNLOAD_EXIT": str(download_exit), + } + if release is not None: + environment["FIGURINE_VERSION"] = release + result = subprocess.run( + ["/bin/bash", "--noprofile", "--norc"], input=harness, + text=True, capture_output=True, env=environment, timeout=10, + ) + return { + "result": result, + "binary": binary.read_bytes(), + "mode": binary.stat().st_mode & 0o777, + "profile": profile.read_text(), + "bashrc": bashrc.read_text(), + "downloads": (root / "downloads").read_text() if (root / "downloads").exists() else "", + "registered": (root / "registered").read_text() if (root / "registered").exists() else "", + "payload": payload, + "binary_path": str(binary), + } + + def test_installs_release_matching_debian_userspace(self): + for arch, asset in (("amd64", "amd64"), ("arm64", "arm64"), ("armhf", "arm")): + with self.subTest(arch=arch): + state = self.run_installer(arch) + self.assertEqual(state["result"].returncode, 0, state["result"].stderr) + self.assertEqual(state["downloads"], + f"https://github.com/arsham/figurine/releases/download/v{VERSION}/" + f"figurine_linux_{asset}_v{VERSION}.tar.gz\n") + self.assertEqual(state["binary"], state["payload"]) + self.assertEqual(state["mode"], 0o755) + self.assertIn(f'{state["binary_path"]} -f "3d.flf" $(hostname)', state["profile"]) + self.assertEqual(state["registered"], f"figurine true {FUNCTION_VERSION}\n") + + def test_falls_back_to_kernel_when_dpkg_is_unavailable(self): + for kernel, asset in (("x86_64", "amd64"), ("aarch64", "arm64"), + ("armv7l", "arm"), ("armv8l", "arm")): + with self.subTest(kernel=kernel): + state = self.run_installer("", kernel=kernel, dpkg_exit=127) + self.assertEqual(state["result"].returncode, 0, state["result"].stderr) + self.assertIn(f"figurine_linux_{asset}_v{VERSION}.tar.gz", state["downloads"]) + + def test_userspace_takes_precedence_over_kernel(self): + state = self.run_installer("armhf", kernel="aarch64") + self.assertEqual(state["result"].returncode, 0, state["result"].stderr) + self.assertIn(f"figurine_linux_arm_v{VERSION}.tar.gz", state["downloads"]) + + def test_fresh_install_creates_binary_banner_and_aliases(self): + state = self.run_installer(existing=False) + self.assertEqual(state["result"].returncode, 0, state["result"].stderr) + self.assertEqual(state["binary"], state["payload"]) + self.assertIn(' -f "3d.flf" $(hostname)', state["profile"]) + self.assertIn("shopt -s expand_aliases", state["bashrc"]) + self.assertIn("alias aptup='apt update && apt dist-upgrade'", state["bashrc"]) + self.assertEqual(state["registered"], f"figurine true {FUNCTION_VERSION}\n") + + def test_unsupported_architecture_fails_before_changes_or_download(self): + for arch in ("i386", "riscv64", "armel", "armv6l", "ppc64el", ""): + with self.subTest(arch=arch): + state = self.run_installer(arch) + self.assertNotEqual(state["result"].returncode, 0) + self.assertIn("Unsupported architecture for Figurine:", state["result"].stderr) + self.assertEqual(state["downloads"], "") + self.assert_preserved(state) + self.assertEqual(state["bashrc"], "# previous bashrc\n") + + def assert_preserved(self, state): + self.assertEqual(state["binary"], b"previous binary\n") + self.assertEqual(state["profile"], "previous profile\n") + self.assertEqual(state["registered"], "") + + def test_download_failure_preserves_previous_installation(self): + state = self.run_installer(download_exit=8) + self.assertNotEqual(state["result"].returncode, 0) + self.assertIn("Failed to download Figurine", state["result"].stderr) + self.assert_preserved(state) + + def test_invalid_packages_preserve_previous_installation(self): + for package, error in (("corrupt", "Failed to extract package"), + ("missing", "Binary not found in extracted content.")): + with self.subTest(package=package): + state = self.run_installer(package=package) + self.assertNotEqual(state["result"].returncode, 0) + self.assertIn(error, state["result"].stderr) + self.assert_preserved(state) + + def test_release_override_is_preserved(self): + state = self.run_installer("arm64", release="2.1.0") + self.assertEqual(state["result"].returncode, 0, state["result"].stderr) + self.assertIn("/v2.1.0/figurine_linux_arm64_v2.1.0.tar.gz", state["downloads"]) + + def test_existing_installation_is_offered_the_update_once(self): + spec = importlib.util.spec_from_file_location( + "post_install_versions", ROOT / "AppImage/scripts/post_install_versions.py") + versions = importlib.util.module_from_spec(spec) + spec.loader.exec_module(versions) + metadata = versions.parse_post_install_script(SCRIPT) + installed = {"figurine": {"installed": True, "version": "1.1", "source": "custom"}} + updates = versions._detect_updates({}, metadata, installed) + self.assertEqual(len(updates), 1) + self.assertEqual(updates[0]["available_version"], FUNCTION_VERSION) + self.assertEqual(updates[0]["function"], "configure_figurine") + installed["figurine"]["version"] = FUNCTION_VERSION + self.assertEqual(versions._detect_updates({}, metadata, installed), []) + + tree = ast.parse((ROOT / "AppImage/scripts/flask_proxmenux_routes.py").read_text()) + fallback = next(ast.literal_eval(node.value) for node in tree.body + if isinstance(node, ast.Assign) + and any(isinstance(t, ast.Name) and t.id == "TOOL_METADATA" + for t in node.targets)) + self.assertEqual(fallback["figurine"]["version"], FUNCTION_VERSION) + + +if __name__ == "__main__": + unittest.main() diff --git a/web/app/[locale]/docs/post-install/optional/page.tsx b/web/app/[locale]/docs/post-install/optional/page.tsx index 258b0b4e..5f1bc657 100644 --- a/web/app/[locale]/docs/post-install/optional/page.tsx +++ b/web/app/[locale]/docs/post-install/optional/page.tsx @@ -312,25 +312,31 @@ EOF
{t("figurine.automates")}