mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-01 10:22:08 +00:00
feat(helper-scripts): an addon runs on the host or inside an LXC, as its catalog says
An addon is run where the catalog lists it: on the Proxmox host, inside an LXC chosen from a list, or wherever is chosen when it can be both. The LXC is started when it is stopped, and OCI containers are left out. Until now every addon ran on the host. A script is downloaded whole before anything runs, so a failed or empty download runs nothing. Descriptions and notes the catalog writes in HTML are shown as plain text. The menu opens one script given as argument, and is as tall as the main menu.
This commit is contained in:
@@ -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="<p>Run <code>ls -la && pwd</code></p><ul><li>one</li><li>two</li></ul>")
|
||||
self.assertEqual(result.stdout, "Run ls -la && pwd\n\n• one\n\n• two")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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()))
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user