diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index b94c42b819..c6ac6c653c 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -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 diff --git a/install.sh b/install.sh index 6d63c75337..60e68c8d51 100755 --- a/install.sh +++ b/install.sh @@ -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 diff --git a/scripts/installtests/install_sh_test.go b/scripts/installtests/install_sh_test.go index 3ba1d996de..5f453f10f9 100644 --- a/scripts/installtests/install_sh_test.go +++ b/scripts/installtests/install_sh_test.go @@ -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") + ` diff --git a/scripts/installtests/root_install_sh_test.go b/scripts/installtests/root_install_sh_test.go index 6948a0e0c2..8e00c4e22c 100644 --- a/scripts/installtests/root_install_sh_test.go +++ b/scripts/installtests/root_install_sh_test.go @@ -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) { diff --git a/scripts/tests/test_server_update_menu.py b/scripts/tests/test_server_update_menu.py new file mode 100644 index 0000000000..70ed62838f --- /dev/null +++ b/scripts/tests/test_server_update_menu.py @@ -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()