diff --git a/.github/scripts/tests/test_helper_addon_target.py b/.github/scripts/tests/test_helper_addon_target.py new file mode 100644 index 00000000..64ba2a9c --- /dev/null +++ b/.github/scripts/tests/test_helper_addon_target.py @@ -0,0 +1,108 @@ +"""An addon of the Helper-Scripts runs where its catalog says it belongs: on +the Proxmox host, inside a container, or wherever the user chooses when it +can be both. Until this, every addon ran on the host.""" + +from pathlib import Path +import subprocess +import textwrap +import unittest + +ROOT = Path(__file__).resolve().parents[3] +MENU = ROOT / "scripts/menus/menu_Helper_Scripts.sh" +PLAIN = ROOT / "scripts/menus/helper_plain_text.py" + +# Only the functions under test are loaded, not the menu or its catalog. +PRELUDE = textwrap.dedent(f""" + set -uo pipefail + eval "$(sed -n '/^download_script() {{/,/^RETURN_TO_MAIN=false/p' '{MENU}' | sed '$d')" + translate() {{ printf '%s' "$1"; }} + clear() {{ :; }} + dialog() {{ :; }} + pmx_lxc_is_oci() {{ [[ "$1" == 100 ]]; }} + MENU_HEIGHT=22; MENU_ROWS=14 +""") + + +def bash(script): + return subprocess.run(["bash", "-c", PRELUDE + textwrap.dedent(script)], capture_output=True, text=True) + + +class TargetChoiceTests(unittest.TestCase): + def choice(self, kind, places): + return bash(f'helper_target_choice {kind} "{places}"').stdout.strip() + + def test_an_addon_for_the_host_runs_on_the_host(self): + self.assertEqual(self.choice("addon", "pve"), "host") + + def test_an_addon_for_containers_runs_in_one(self): + self.assertEqual(self.choice("addon", "lxc vm"), "lxc") + self.assertEqual(self.choice("addon", "lxc"), "lxc") + + def test_an_addon_for_both_asks_where(self): + self.assertEqual(self.choice("addon", "pve vm lxc"), "ask") + self.assertEqual(self.choice("addon", ""), "ask") + + def test_what_is_not_an_addon_runs_on_the_host(self): + # A script that creates a container or a VM belongs on the host, + # whatever the catalog lists for the application it installs. + self.assertEqual(self.choice("lxc", "lxc"), "host") + self.assertEqual(self.choice("vm", "vm"), "host") + self.assertEqual(self.choice("pve", "pve"), "host") + + +class ContainerListTests(unittest.TestCase): + def test_oci_containers_are_left_out_and_a_lock_is_shown(self): + result = bash(r""" + pct() { printf 'VMID Status Lock Name\n100 running chromium\n109 running ordinary\n110 stopped stopped-one\n112 stopped backup locked-one\n'; } + helper_lxc_rows + """) + self.assertEqual(result.stdout, + "109\tordinary · running\n110\tstopped-one · stopped\n112\tlocked-one · stopped · backup\n") + + +class RunTests(unittest.TestCase): + def test_the_script_runs_on_the_host_or_inside_the_container_chosen(self): + result = bash(r""" + curl() { printf 'printf on-host'; } + pct() { [[ "$1" == exec && "$2" == 109 && "$3" == --keep-env && "$4" == 0 && "$5" == -- ]] && printf 'in-lxc:%s' "${*: -1}"; } + HELPER_LXC_TARGET=''; download_script https://example.invalid/a; echo + HELPER_LXC_TARGET=109; download_script https://example.invalid/a; echo + """) + self.assertEqual(result.stdout, "on-host\nin-lxc:printf on-host\n") + + def test_a_failed_or_empty_download_runs_nothing(self): + result = bash(r""" + bash() { echo RAN; } + pct() { echo RAN; } + HELPER_LXC_TARGET=109 + curl() { return 22; }; download_script https://example.invalid/a && echo UNEXPECTED + curl() { printf ' \n'; }; download_script https://example.invalid/a && echo UNEXPECTED + echo done + """) + self.assertEqual(result.stdout, "done\n") + + def test_cancelling_the_choice_leaves_no_target(self): + result = bash(r""" + dialog() { return 1; } + select_helper_target Test ask && echo UNEXPECTED + printf '[%s]\n' "$HELPER_LXC_TARGET" + """) + self.assertEqual(result.stdout, "[]\n") + + def test_the_host_needs_no_question(self): + result = bash(r""" + dialog() { echo ASKED; return 1; } + select_helper_target Test host && printf 'ok[%s]\n' "$HELPER_LXC_TARGET" + """) + self.assertEqual(result.stdout, "ok[]\n") + + +class PlainTextTests(unittest.TestCase): + def test_html_becomes_text_and_commands_are_kept(self): + result = subprocess.run(["python3", str(PLAIN)], capture_output=True, text=True, + input="

Run ls -la && pwd

") + self.assertEqual(result.stdout, "Run ls -la && pwd\n\n• one\n\n• two") + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/menus/helper_plain_text.py b/scripts/menus/helper_plain_text.py new file mode 100644 index 00000000..b81857d5 --- /dev/null +++ b/scripts/menus/helper_plain_text.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +"""Convert catalogue HTML to terminal text without altering shell examples.""" +import re +import sys +from html.parser import HTMLParser + + +class PlainText(HTMLParser): + def __init__(self): + super().__init__(convert_charrefs=True) + self.parts = [] + self.hidden = 0 + + def handle_starttag(self, tag, attrs): + if tag in ('script', 'style'): + self.hidden += 1 + elif not self.hidden: + if tag in ('p', 'div', 'br', 'ul', 'ol', 'pre', 'blockquote', + 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'): + self.parts.append('\n') + elif tag == 'li': + self.parts.append('\n• ') + + def handle_endtag(self, tag): + if tag in ('script', 'style'): + self.hidden = max(0, self.hidden - 1) + elif not self.hidden and tag in ('p', 'div', 'li', 'ul', 'ol', 'pre', + 'blockquote', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'): + self.parts.append('\n') + + def handle_data(self, data): + if not self.hidden: + self.parts.append(data) + + +def plain_text(value): + parser = PlainText() + parser.feed(value) + parser.close() + result = ''.join(parser.parts).replace('\xa0', ' ') + result = '\n'.join(line.strip() for line in result.splitlines()) + return re.sub(r'\n{3,}', '\n\n', result).strip() + + +if __name__ == '__main__': + sys.stdout.write(plain_text(sys.stdin.read())) diff --git a/scripts/menus/menu_Helper_Scripts.sh b/scripts/menus/menu_Helper_Scripts.sh index add6d3ea..ab0fcaf0 100644 --- a/scripts/menus/menu_Helper_Scripts.sh +++ b/scripts/menus/menu_Helper_Scripts.sh @@ -60,15 +60,22 @@ done # Without these the menu would hang silently for minutes when the network # is down or the upstream is slow, with no signal to the user. Audit # Tier 6 — `menu_Helper_Scripts.sh` curl sin --max-time. -CACHE_JSON=$(curl -s --connect-timeout 5 --max-time 15 "$HELPERS_JSON_URL") +CACHE_JSON=$(curl -fsSL --connect-timeout 5 --max-time 15 "$HELPERS_JSON_URL") # Validate that the JSON loaded correctly if ! echo "$CACHE_JSON" | jq -e 'if type == "array" and length > 0 then true else false end' >/dev/null 2>&1; then dialog --title "Helper Scripts" \ --msgbox "Error: Could not load helpers cache.\nCheck your internet connection and try again.\n\nURL: $HELPERS_JSON_URL" 10 70 + # Opened for one script, as the menu search does: back to where it came from. + [[ -n "${1:-}" ]] && exit 1 exec bash "$LOCAL_SCRIPTS/menus/main_menu.sh" fi +# The menus are as tall as the main menu, cut to the terminal. +MENU_HEIGHT=$(( $(tput lines 2>/dev/null || echo 24) - 2 )) +(( MENU_HEIGHT > 24 )) && MENU_HEIGHT=24 +MENU_ROWS=$(( MENU_HEIGHT - 8 )) + # --------------------------------------------------------------------------- # Build category map directly from the cache (id → name). # Uses transpose to pair categories[] and category_names[] arrays — no @@ -107,15 +114,99 @@ get_type_label() { # Download and execute a script URL, with optional mirror fallback # --------------------------------------------------------------------------- download_script() { - local url="$1" + local url="$1" script_content - # Same timeouts as the cache fetch above so a stalled mirror can't hang - # the menu indefinitely. The HEAD probe and the GET both get bounded. - if curl --silent --connect-timeout 5 --max-time 10 --head --fail "$url" >/dev/null; then - bash <(curl -s --connect-timeout 5 --max-time 60 "$url") - else + # The script is downloaded whole before anything runs: a download that + # fails or comes back empty runs nothing, on the host or in a container. + if ! script_content=$(curl -fsSL --connect-timeout 5 --max-time 60 "$url") || [[ -z "${script_content//[[:space:]]/}" ]]; then dialog --title "Helper Scripts" --msgbox "$(translate "Error: Failed to download the script.")" 8 70 + return 1 fi + clear + if [[ -n "${HELPER_LXC_TARGET:-}" ]]; then + pct exec "$HELPER_LXC_TARGET" --keep-env 0 -- \ + env TERM="${TERM:-xterm}" bash -c "$script_content" + else + bash -c "$script_content" + fi +} + +# --------------------------------------------------------------------------- +# Where an addon runs. The catalog says it for each one: on the Proxmox host, +# inside a container, or either. Everything that is not an addon runs on the +# host, which is where a script that creates a container or a VM belongs. +# $1 type $2 the "execute_in" of the catalog, separated by spaces +# Prints host, lxc or ask. +# --------------------------------------------------------------------------- +helper_target_choice() { + local type="$1" places=" $2 " host=0 lxc=0 + [[ "$type" == addon ]] || { echo host; return; } + [[ "$places" == *" pve "* ]] && host=1 + [[ "$places" == *" lxc "* ]] && lxc=1 + if (( host == 1 && lxc == 0 )); then + echo host + elif (( lxc == 1 && host == 0 )); then + echo lxc + else + echo ask + fi +} + +# The containers an addon can be run in: every LXC but the OCI ones, which +# are an application image and not a system to add things to. +helper_lxc_rows() { + local ctid status lock name + while read -r ctid status lock name; do + [[ "$ctid" =~ ^[0-9]+$ ]] || continue + pmx_lxc_is_oci "$ctid" && continue + # pct list leaves the Lock column blank for a container that has none. + [[ -n "$name" ]] || { name="$lock"; lock=""; } + printf '%s\t%s · %s%s\n' "$ctid" "$name" "$status" "${lock:+ · $lock}" + done < <(pct list) +} + +# Asks where to run an addon and leaves the container in HELPER_LXC_TARGET, +# empty for the host. Returns 1 when nothing has to run. +# $1 name of the addon $2 host, lxc or ask +select_helper_target() { + HELPER_LXC_TARGET="" + local title="$1" target="$2" ctid label state + [[ "$target" == host ]] && return 0 + if [[ "$target" == ask ]]; then + target=$(dialog --clear --backtitle "ProxMenux" --title "$title" \ + --menu "$(translate "Where do you want to run this addon?")" 12 75 2 \ + lxc "$(translate "In an LXC")" \ + host "$(translate "On the host")" 3>&1 1>&2 2>&3) || return 1 + [[ "$target" == host ]] && return 0 + fi + [[ "$target" == lxc ]] || return 1 + local -a containers=() + while IFS=$'\t' read -r ctid label; do + containers+=("$ctid" "$label") + done < <(helper_lxc_rows) + if [[ ${#containers[@]} -eq 0 ]]; then + dialog --title "$title" --msgbox "$(translate "There are no LXC containers available. OCI containers are not included.")" 9 75 + return 1 + fi + ctid=$(dialog --clear --backtitle "ProxMenux" --title "$title · LXC" \ + --menu "$(translate "Select the LXC. OCI containers are not included.")" "$MENU_HEIGHT" 75 "$MENU_ROWS" \ + "${containers[@]}" 3>&1 1>&2 2>&3) || return 1 + [[ "$ctid" =~ ^[0-9]+$ && -f "/etc/pve/lxc/$ctid.conf" ]] || return 1 + pmx_lxc_is_oci "$ctid" && return 1 + state=$(pct status "$ctid") || return 1 + if [[ "$state" != "status: running" ]]; then + dialog --backtitle "ProxMenux" --title "$title · LXC $ctid" \ + --yesno "\n$(translate "The LXC is stopped. Do you want to start it to run the addon?")" 10 75 || return 1 + if ! pct start "$ctid"; then + dialog --title "$title · LXC $ctid" --msgbox "$(translate "The LXC could not be started.")" 8 75 + return 1 + fi + fi + if ! pct exec "$ctid" --keep-env 0 -- sh -c 'command -v bash >/dev/null'; then + dialog --title "$title · LXC $ctid" --msgbox "$(translate "The LXC has no Bash to run this script with. The addon was not run.")" 9 75 + return 1 + fi + HELPER_LXC_TARGET="$ctid" } RETURN_TO_MAIN=false @@ -172,13 +263,15 @@ run_script_by_slug() { local first="${script_infos[0]}" local name desc notes warnings port website name=$(decode "$first" ".name") - desc=$(decode "$first" ".desc") - notes=$(decode "$first" '.notes // [] | join("\n")') + # The catalog writes some of its texts in HTML. + local plain="$LOCAL_SCRIPTS/menus/helper_plain_text.py" + desc=$(decode "$first" ".desc" | python3 "$plain") + notes=$(decode "$first" '.notes // [] | join("\n")' | python3 "$plain") # Sprint 11.7: PocketBase upstream tags certain notes with type=="warning". # The cache generator splits them into a separate `warnings` array so the # menu can render them in red with a dedicated WARNINGS header instead of # burying them inside the regular notes block. - warnings=$(decode "$first" '.warnings // [] | join("\n")') + warnings=$(decode "$first" '.warnings // [] | join("\n")' | python3 "$plain") port=$(decode "$first" ".port // 0") website=$(decode "$first" ".website // empty") @@ -271,6 +364,10 @@ run_script_by_slug() { gh_url=$(decode "$selected" ".script_url") mirror_url=$(decode "$selected" ".script_url_mirror // empty") + # An addon is run where the catalog says it belongs. + select_helper_target "$name" "$(helper_target_choice \ + "$(decode "$selected" '.type // empty')" "$(decode "$selected" '.execute_in // [] | join(" ")')")" || return + if [[ "$sel_src" == "GH" ]]; then download_script "$gh_url" elif [[ "$sel_src" == "MR" ]]; then @@ -388,7 +485,7 @@ search_and_filter_scripts() { selected=$(dialog --colors --backtitle "ProxMenux" \ --title "$title" \ --menu "$(translate "Select a script or action:")" \ - 22 75 15 "${menu_items[@]}" 3>&1 1>&2 2>&3) + "$MENU_HEIGHT" 75 "$MENU_ROWS" "${menu_items[@]}" 3>&1 1>&2 2>&3) [[ $? -ne 0 ]] && return @@ -416,6 +513,12 @@ search_and_filter_scripts() { done } +# A script given as argument, as the menu search does, is opened on its own. +if [[ -n "${1:-}" ]]; then + run_script_by_slug "$1" + exit 0 +fi + # --------------------------------------------------------------------------- # Main loop — category list built from embedded category data. # We map scriptcatXXXXX IDs to short numeric indices so dialog doesn't show @@ -442,7 +545,7 @@ while true; do SELECTED_IDX=$(dialog --backtitle "ProxMenux" \ --title "Proxmox VE Helper-Scripts" \ --menu "$(translate "Select a category or search for scripts:")" \ - 22 75 15 "${MENU_ITEMS[@]}" 3>&1 1>&2 2>&3) || { + "$MENU_HEIGHT" 75 "$MENU_ROWS" "${MENU_ITEMS[@]}" 3>&1 1>&2 2>&3) || { dialog --clear --title "ProxMenux" \ --msgbox "\n\n$(translate "Visit the website to discover more scripts, stay updated with the latest updates, and support the project:")\n\nhttps://community-scripts.github.io/ProxmoxVE" 15 70 exec bash "$LOCAL_SCRIPTS/menus/main_menu.sh" @@ -484,7 +587,7 @@ while true; do SCRIPT_INDEX=$(dialog --colors --backtitle "ProxMenux" \ --title "$(translate "Scripts in") ${CATEGORY_NAMES[$SELECTED]}" \ --menu "$(translate "Choose a script to execute:"):" \ - 22 75 15 "${SCRIPTS[@]}" 3>&1 1>&2 2>&3) || break + "$MENU_HEIGHT" 75 "$MENU_ROWS" "${SCRIPTS[@]}" 3>&1 1>&2 2>&3) || break SCRIPT_SELECTED="${INDEX_TO_SLUG[$SCRIPT_INDEX]}" run_script_by_slug "$SCRIPT_SELECTED"