Bind unattended installer updates to exact menu targets

Use the reviewed server-only selector in the existing-installation menu and bind automatic selection to available channel rows. A current selected release is a no-op; unavailable metadata or an automatic downgrade stops before mutations. Preview selection retains its channel, numeric beta/RC revisions compare correctly, and deliberate reinstall pins the actual current version.

Execute the actual menu through confined offline transport and stopped mutation boundaries. Preserve explicit version, interactive removal, signature and all-unit quiescence contracts; reconcile the inherited comment-only preview assertion with the real selector consumer.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-10-09 09:20:16 +01:00
parent 7956e32071
commit b155982e30
5 changed files with 373 additions and 21 deletions
@@ -15,6 +15,37 @@
## Purpose
### Existing-installation menu actions require exact target and intent
The root server installer's existing-installation menu admits stable/preview
targets through the same complete, published server-only release selector as its
installer and archive downloads. Chart tags, drafts, malformed metadata and
failed transport output must not become offered versions. Explicit version pins
remain a separate deliberate route; signature and archive admission are unchanged.
Without interactive input (including Docker), only a newer release in the
requested/configured channel may select an update. Selection uses the actual
available row, never an assumed option number: absent or current versions can
otherwise move preview, reinstall or removal into that number. A current selected
version is an unchanged no-op; unavailable metadata or an automatic downgrade
returns nonzero before readiness, backup, auto-update consent or any mutation.
Preview following the newest stable preserves the preview channel preference.
Beta/RC revision ordering is numeric within the same stage, so `.10` upgrades
`.9` and the reverse remains an automatic rollback refusal.
Interactive update, removal and cancellation retain their displayed meanings.
Explicit reinstall pins the known current server version rather than discovering
and installing latest; an unknown current version requires a deliberate exact
pin instead. Removal still requires the independent all-unit quiescence checks.
`TestRootInstallExistingMenuRequiresExactAutomaticIntent` executes eighteen
ordinary-user controls against actual sourced installer `main`, with confined
release transport and stopped mutation boundaries. They cover disappearing rows,
both automatic entry paths, channel intent, chart/draft/metadata refusal, automatic
rollback refusal, exact reinstall and deliberate removal/cancel. The same controls
fail the prior menu; they do not establish native systemd, signed installation,
published availability or customer recovery.
### Windows replacement and removal refuse uncertain service state
Before replacing an agent binary or changing its token/connection state, and
+71 -20
View File
@@ -2797,6 +2797,17 @@ compare_versions() {
elif [[ -n "$suffix_v1" ]] && [[ -z "$suffix_v2" ]]; then
return 2 # v1 (rc) < v2 (stable)
elif [[ -n "$suffix_v1" ]] && [[ -n "$suffix_v2" ]]; then
# Published preview revisions are numeric: rc.10 follows rc.9, not
# vice versa. Keep the existing lexical fallback for other suffixes.
if [[ "$suffix_v1" =~ ^(beta|rc)[.][0-9]+$ && "$suffix_v2" =~ ^(beta|rc)[.][0-9]+$ && "${suffix_v1%.*}" == "${suffix_v2%.*}" ]]; then
local revision_v1="${suffix_v1##*.}" revision_v2="${suffix_v2##*.}"
if (( 10#$revision_v1 > 10#$revision_v2 )); then
return 1
elif (( 10#$revision_v1 < 10#$revision_v2 )); then
return 2
fi
return 0
fi
# Both have suffixes, compare them lexicographically
if [[ "$suffix_v1" > "$suffix_v2" ]]; then
return 1
@@ -5079,19 +5090,13 @@ main() {
return 0
fi
# Get both stable and RC versions
# Try GitHub API first, but have a fallback - with timeout protection
# Use the same complete, server-only metadata admission as downloads.
# A chart tag or a draft must never become an existing-install action.
local STABLE_VERSION=""
STABLE_VERSION=$(resolve_latest_release_tag_for_channel stable 2>/dev/null || true)
# For RC, we need the API, so if it fails just use empty
local RC_VERSION=""
if command -v timeout >/dev/null 2>&1; then
RC_VERSION=$(timeout 10 curl -s --connect-timeout 5 --max-time 10 https://api.github.com/repos/$GITHUB_REPO/releases 2>/dev/null | grep '"tag_name":' | head -1 | sed -E 's/.*"([^"]+)".*/\1/' || true)
else
RC_VERSION=$(curl -s --connect-timeout 5 --max-time 10 https://api.github.com/repos/$GITHUB_REPO/releases 2>/dev/null | grep '"tag_name":' | head -1 | sed -E 's/.*"([^"]+)".*/\1/' || true)
fi
RC_VERSION=$(resolve_latest_release_tag_for_channel rc 2>/dev/null || true)
# Determine default update channel
UPDATE_CHANNEL="stable"
@@ -5110,12 +5115,15 @@ main() {
# Show update options based on available versions
local menu_option=1
local stable_choice="" rc_choice=""
if [[ -n "$STABLE_VERSION" ]] && [[ "$STABLE_VERSION" != "$CURRENT_VERSION" ]]; then
stable_choice=$menu_option
echo "${menu_option}) Update to $STABLE_VERSION (stable)"
((menu_option++))
fi
if [[ -n "$RC_VERSION" ]] && [[ "$RC_VERSION" != "$STABLE_VERSION" ]] && [[ "$RC_VERSION" != "$CURRENT_VERSION" ]]; then
rc_choice=$menu_option
echo "${menu_option}) Update to $RC_VERSION (prerelease preview)"
((menu_option++))
fi
@@ -5129,27 +5137,59 @@ main() {
# Try to read user choice interactively
# safe_read handles both normal and piped input (via /dev/tty)
local automatic_choice=false
local requested_channel="$UPDATE_CHANNEL"
if [[ "$IN_DOCKER" == "true" ]]; then
# In Docker, always auto-select
print_info "Docker environment detected. Auto-selecting update option."
if [[ "$UPDATE_CHANNEL" == "rc" ]] && [[ -n "$RC_VERSION" ]] && [[ "$RC_VERSION" != "$STABLE_VERSION" ]]; then
choice=2 # RC version
else
choice=1 # Stable version
fi
automatic_choice=true
elif safe_read "Select option [1-${max_option}]: " choice; then
# Successfully read user choice (either from stdin or /dev/tty)
: # Do nothing, choice was set
else
# safe_read failed - truly non-interactive
print_info "Non-interactive mode detected. Auto-selecting update option."
if [[ "$UPDATE_CHANNEL" == "rc" ]] && [[ -n "$RC_VERSION" ]] && [[ "$RC_VERSION" != "$STABLE_VERSION" ]]; then
choice=2 # RC version
else
choice=1 # Stable version
fi
automatic_choice=true
fi
if [[ "$automatic_choice" == true ]]; then
# Rows disappear when a release is unavailable or already installed.
# Fixed option numbers could then select preview, reinstall or REMOVE.
# Only a newer release in the requested channel is an automatic action.
local automatic_release="$STABLE_VERSION"
choice="$stable_choice"
if [[ "$requested_channel" == rc ]]; then
automatic_release="$RC_VERSION"
if [[ "$RC_VERSION" == "$STABLE_VERSION" ]]; then
choice="$stable_choice"
else
choice="$rc_choice"
fi
fi
if [[ -z "$automatic_release" ]]; then
print_error "Cannot determine a published Pulse server release for the $requested_channel channel. Existing installation is unchanged; check connectivity and jq, or deliberately choose an exact version with --version."
return 1
fi
if [[ -n "$CURRENT_VERSION" && "$CURRENT_VERSION" != unknown ]]; then
local automatic_compare=0
compare_versions "$automatic_release" "$CURRENT_VERSION" || automatic_compare=$?
case "$automatic_compare" in
0)
print_info "$CURRENT_VERSION is already the selected $requested_channel release. Existing installation is unchanged."
return 0
;;
2)
print_error "Selected release $automatic_release is older than $CURRENT_VERSION; refusing to downgrade automatically. Existing installation is unchanged; use --version only for a deliberate rollback."
return 1
;;
esac
fi
if [[ -z "$choice" ]]; then
print_error "No update option matches the selected release. Existing installation is unchanged."
return 1
fi
fi
# Debug: Check if choice was read correctly
if [[ -z "$choice" ]]; then
print_error "No option selected. Exiting."
@@ -5201,6 +5241,12 @@ main() {
action="cancel"
fi
# Preview can legitimately select the newest stable release. Do not
# silently turn that requested preview channel into a stable preference.
if [[ "$automatic_choice" == true && "$action" == update ]]; then
UPDATE_CHANNEL="$requested_channel"
fi
# Debug: Show what action was determined
# print_info "DEBUG: Action determined: ${action:-'none'}"
@@ -5251,6 +5297,11 @@ main() {
exit 0
;;
reinstall)
if ! is_pulse_release_tag "$CURRENT_VERSION"; then
print_error "Cannot reinstall an unknown current server version. Existing installation is unchanged; deliberately choose an exact version with --version."
return 1
fi
LATEST_RELEASE="$CURRENT_VERSION"
offer_existing_auto_updates
backup_existing
+13
View File
@@ -4514,6 +4514,19 @@ func TestRootInstallReleaseSelectionRejectsChartAndUnknownMetadata(t *testing.T)
}
}
// Exercise installer main only with confined transport and mutation doubles.
// In particular, a mistaken remove choice exits before any real removal code.
func TestRootInstallExistingMenuRequiresExactAutomaticIntent(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "python3", repoFile("scripts", "tests", "test_server_update_menu.py"), "-v")
cmd.Env = append(os.Environ(), "PULSE_INSTALLER_UNDER_TEST="+repoFile("install.sh"))
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("existing installer menu consent/target controls: %v\n%s", err, out)
}
}
func TestRootInstallStableReleaseTagRejectsPrereleaseShapes(t *testing.T) {
script := `
` + extractRootInstallShellFunction(t, "is_stable_release_tag") + `
+3 -1
View File
@@ -793,7 +793,9 @@ func TestPrereleaseUpdateCopyUsesPreviewFraming(t *testing.T) {
`Update to $RC_VERSION (prerelease preview)`,
`--rc, --pre Install latest prerelease preview version`,
`Prerelease channel detected in configuration`,
`Prerelease channel: get latest release (including prereleases, but skip drafts)`,
// Bind the menu to the complete server-release selector rather than
// requiring a removed, non-user-facing implementation comment.
`RC_VERSION=$(resolve_latest_release_tag_for_channel rc 2>/dev/null || true)`,
}
for _, needle := range requiredInstall {
if !strings.Contains(installScript, needle) {
+255
View File
@@ -0,0 +1,255 @@
#!/usr/bin/env python3
"""Exercise the actual existing-installation menu without host mutations.
Release transport and all mutation boundaries are confined doubles. These are
ordinary-user selection/consent controls, not a native installation proof.
"""
import json
import os
from pathlib import Path
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[2]
INSTALLER = Path(os.environ.get("PULSE_INSTALLER_UNDER_TEST", ROOT / "install.sh"))
def release(tag, *, draft=False, prerelease=False):
return {"tag_name": tag, "draft": draft, "prerelease": prerelease}
HARNESS = r'''
source "$INSTALLER"
FORCE_VERSION="$PIN"
FORCE_CHANNEL="$CHANNEL"
CURRENT_VERSION="$INSTALLED"
IN_CONTAINER=true
IN_DOCKER="$DOCKER"
print_header() { :; }
print_info() { printf '%s\n' "$*"; }
print_warn() { printf '%s\n' "$*"; }
print_error() { printf '%s\n' "$*" >&2; }
check_root() { :; }
detect_os() { :; }
check_docker_environment() { :; }
check_existing_installation() { return 0; }
check_proxmox_host() { return 1; }
create_lxc_container() { exit 96; }
timeout() { shift; "$@"; }
curl() {
local url="${@: -1}"
printf '%s\n' "$url" >> "$FIXTURE/transport"
case "$url" in
https://api.github.com/repos/rcourtman/Pulse/releases)
printf '%s' "$RELEASES_JSON"
return "$API_EXIT" ;;
https://github.com/rcourtman/Pulse/releases/latest)
[[ -n "$REDIRECT_TAG" ]] || return 22
printf 'https://github.com/rcourtman/Pulse/releases/tag/%s' "$REDIRECT_TAG" ;;
*) printf 'unexpected transport: %s\n' "$url" >&2; exit 97 ;;
esac
}
if [[ "$NO_JQ" == true ]]; then
command() {
[[ "$*" != '-v jq' ]] || return 1
builtin command "$@"
}
fi
safe_read() {
[[ "$MENU_CHOICE" != eof ]] || return 1
printf -v "$2" '%s' "$MENU_CHOICE"
}
# Every reachable mutation is stopped before the real implementation, including
# removal. A wrong automatic choice is evidence, never an actual uninstall.
offer_existing_auto_updates() { echo CONSENT_PROMPT >> "$FIXTURE/mutations"; }
detect_service_name() { echo pulse; }
run_upgrade_readiness_preflight() { echo PREFLIGHT >> "$FIXTURE/mutations"; }
backup_existing() { echo BACKUP >> "$FIXTURE/mutations"; }
create_user() { echo USER >> "$FIXTURE/mutations"; }
download_pulse() {
printf 'DOWNLOAD=%s CHANNEL=%s\n' "${LATEST_RELEASE:-unpinned}" "${UPDATE_CHANNEL:-}" >> "$FIXTURE/mutations"
exit 0
}
quiesce_pulse_for_removal() { echo REMOVE >> "$FIXTURE/mutations"; exit 77; }
main
'''
class ServerUpdateMenuTest(unittest.TestCase):
def exercise(self, releases, installed="v6.5.0", channel="stable", *,
choice="eof", docker=False, configured="stable", api_exit=0,
redirect="", no_jq=False, pinned="", compact=False):
payload = (releases if isinstance(releases, str) else
json.dumps(releases, indent=None if compact else 2))
with tempfile.TemporaryDirectory() as temporary:
fixture = Path(temporary)
config = fixture / "config"
config.mkdir()
(config / "system.json").write_text(json.dumps({"updateChannel": configured}))
env = {**os.environ, "INSTALLER": str(INSTALLER), "FIXTURE": str(fixture),
"PULSE_CONFIG_DIR": str(config), "RELEASES_JSON": payload,
"INSTALLED": installed, "CHANNEL": channel, "MENU_CHOICE": choice,
"DOCKER": str(docker).lower(), "API_EXIT": str(api_exit),
"REDIRECT_TAG": redirect, "NO_JQ": str(no_jq).lower(), "PIN": pinned}
result = subprocess.run(["bash", "-c", HARNESS], env=env,
capture_output=True, text=True, timeout=15)
def lines(name):
return (fixture / name).read_text().splitlines() if (fixture / name).exists() else []
return result, lines("mutations"), lines("transport")
def assert_unchanged(self, result, mutations, *, refused=False):
self.assertEqual(result.returncode, 1 if refused else 0, result.stdout + result.stderr)
self.assertEqual(mutations, [], result.stdout + result.stderr)
def assert_download(self, result, mutations, version, channel):
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(mutations[-1:], [f"DOWNLOAD={version} CHANNEL={channel}"])
self.assertNotIn("REMOVE", mutations)
def test_current_preview_without_stable_never_autoselects_removal(self):
data = [release("v6.6.0-rc.1", prerelease=True)]
for docker in (False, True):
with self.subTest(docker=docker):
r, mutations, _ = self.exercise(data, "v6.6.0-rc.1", "rc", docker=docker)
self.assert_unchanged(r, mutations)
def test_preview_only_row_is_selected_by_its_actual_index(self):
data = [release("v6.6.0-rc.1", prerelease=True), release("v6.5.0")]
for docker in (False, True):
r, mutations, _ = self.exercise(data, channel="rc", docker=docker)
self.assert_download(r, mutations, "v6.6.0-rc.1", "rc")
def test_stable_at_current_version_does_not_switch_to_preview_or_reinstall(self):
data = [release("v6.6.0-rc.1", prerelease=True), release("v6.5.0")]
for docker in (False, True):
r, mutations, _ = self.exercise(data, docker=docker)
self.assert_unchanged(r, mutations)
r, mutations, _ = self.exercise([release("v6.5.0")])
self.assert_unchanged(r, mutations)
def test_chart_and_draft_metadata_do_not_enter_the_menu(self):
data = [release("helm-chart-6.7.0-rc.1", prerelease=True),
release("v6.7.0-rc.1", draft=True, prerelease=True),
release("helm-chart-6.6.0"), release("v6.6.0-rc.1", prerelease=True),
release("v6.5.0")]
for compact in (False, True):
r, mutations, _ = self.exercise(data, channel="rc", compact=compact)
self.assert_download(r, mutations, "v6.6.0-rc.1", "rc")
self.assertNotIn("Update to helm-chart", r.stdout)
self.assertNotIn("Update to v6.7.0", r.stdout)
def test_new_stable_and_preview_keep_distinct_targets(self):
data = [release("v6.7.0-rc.1", prerelease=True), release("v6.6.0")]
for channel, target in (("stable", "v6.6.0"), ("rc", "v6.7.0-rc.1")):
r, mutations, _ = self.exercise(data, channel=channel)
self.assert_download(r, mutations, target, channel)
def test_preview_channel_can_follow_stable_without_losing_channel_intent(self):
data = [release("v6.6.0"), release("v6.6.0-rc.1", prerelease=True)]
r, mutations, _ = self.exercise(data, channel="rc")
self.assert_download(r, mutations, "v6.6.0", "rc")
def test_configured_channel_and_explicit_override_are_respected(self):
data = [release("v6.6.0-rc.1", prerelease=True), release("v6.5.0")]
r, mutations, _ = self.exercise(data, channel="", configured="rc")
self.assert_download(r, mutations, "v6.6.0-rc.1", "rc")
r, mutations, _ = self.exercise(data, channel="stable", configured="rc")
self.assert_unchanged(r, mutations)
def test_unavailable_or_malformed_metadata_never_falls_into_another_action(self):
for payload in ([], "", "null", '[{"tag_name":"v6.6.0"',
[release("helm-chart-6.6.0")], [release("v6.6.0", draft=True)]):
for channel in ("stable", "rc"):
with self.subTest(payload=payload, channel=channel):
r, mutations, _ = self.exercise(payload, channel=channel)
self.assert_unchanged(r, mutations, refused=True)
self.assertIn("published Pulse server release", r.stderr)
def test_failed_api_payload_and_missing_parser_do_not_admit_preview(self):
data = [release("v6.6.0-rc.1", prerelease=True), release("v6.5.0")]
for options in ({"api_exit": 22}, {"no_jq": True}):
r, mutations, _ = self.exercise(data, channel="rc", **options)
self.assert_unchanged(r, mutations, refused=True)
r, mutations, _ = self.exercise(data, channel="rc", redirect="v6.5.0", **options)
self.assert_unchanged(r, mutations)
def test_automatic_downgrade_is_refused_but_deliberate_menu_choice_is_retained(self):
data = [release("v6.5.0")]
r, mutations, _ = self.exercise(data, "v6.6.0-rc.1")
self.assert_unchanged(r, mutations, refused=True)
self.assertIn("refusing to downgrade automatically", r.stderr)
r, mutations, _ = self.exercise(data, "v6.6.0-rc.1", choice="1")
self.assert_download(r, mutations, "v6.5.0", "stable")
def test_equivalent_version_spelling_is_an_automatic_noop(self):
r, mutations, _ = self.exercise([release("6.5.0")])
self.assert_unchanged(r, mutations)
def test_preview_revision_upgrade_uses_numeric_not_lexical_order(self):
for stage in ("beta", "rc"):
with self.subTest(stage=stage):
target = f"v6.6.0-{stage}.10"
data = [release(target, prerelease=True), release("v6.5.0")]
r, mutations, _ = self.exercise(data, f"v6.6.0-{stage}.9", "rc")
self.assert_download(r, mutations, target, "rc")
r, mutations, _ = self.exercise(data, target, "rc")
self.assert_unchanged(r, mutations)
def test_preview_stage_and_reverse_revision_are_not_downgrades_in_disguise(self):
data = [release("v6.6.0-rc.1", prerelease=True), release("v6.5.0")]
r, mutations, _ = self.exercise(data, "v6.6.0-beta.99", "rc")
self.assert_download(r, mutations, "v6.6.0-rc.1", "rc")
for target, installed in (("v6.6.0-rc.9", "v6.6.0-rc.10"),
("v6.6.0-beta.99", "v6.6.0-rc.1")):
data = [release(target, prerelease=True), release("v6.5.0")]
r, mutations, _ = self.exercise(data, installed, "rc")
self.assert_unchanged(r, mutations, refused=True)
def test_explicit_reinstall_pins_current_version_not_latest(self):
data = [release("v6.7.0-rc.1", prerelease=True), release("v6.6.0")]
r, mutations, _ = self.exercise(data, choice="3")
self.assert_download(r, mutations, "v6.5.0", "stable")
r, mutations, _ = self.exercise(data, installed="unknown", choice="3")
self.assert_unchanged(r, mutations, refused=True)
def test_deliberate_update_rows_remove_and_cancel_have_exact_meaning(self):
data = [release("v6.7.0-rc.1", prerelease=True), release("v6.6.0")]
for choice, target, channel in (("1", "v6.6.0", "stable"),
("2", "v6.7.0-rc.1", "rc")):
r, mutations, _ = self.exercise(data, choice=choice)
self.assert_download(r, mutations, target, channel)
r, mutations, _ = self.exercise(data, choice="4")
self.assertEqual(r.returncode, 77)
self.assertEqual(mutations, ["REMOVE"])
r, mutations, _ = self.exercise(data, choice="5")
self.assert_unchanged(r, mutations)
def test_manual_rows_stay_correct_when_stable_update_is_absent(self):
data = [release("v6.6.0-rc.1", prerelease=True), release("v6.5.0")]
r, mutations, _ = self.exercise(data, choice="1")
self.assert_download(r, mutations, "v6.6.0-rc.1", "rc")
r, mutations, _ = self.exercise(data, choice="2")
self.assert_download(r, mutations, "v6.5.0", "stable")
r, mutations, _ = self.exercise(data, choice="3")
self.assertEqual(r.returncode, 77)
self.assertEqual(mutations, ["REMOVE"])
r, mutations, _ = self.exercise(data, choice="4")
self.assert_unchanged(r, mutations)
def test_invalid_or_empty_manual_choice_never_mutates(self):
for choice in ("", "0", "99", "not-a-number"):
r, mutations, _ = self.exercise([release("v6.6.0")], choice=choice)
self.assert_unchanged(r, mutations, refused=True)
def test_explicit_version_bypasses_menu_and_discovery(self):
r, mutations, transport = self.exercise([], pinned="v6.6.0-rc.1")
self.assert_download(r, mutations, "v6.6.0-rc.1", "")
self.assertEqual(transport, [])
self.assertNotIn("What would you like to do?", r.stdout)
if __name__ == "__main__":
unittest.main()