diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index d0fb60661..a2fea90d6 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -2570,6 +2570,30 @@ artifact-selection behaviour. ## Current State +### Existing-install auto-update consent (1 October 2026) + +A manual update, version-pinned rollback or reinstall is not consent to turn +on unattended updates. The root server installer's three existing-install +prompt paths share `offer_existing_auto_updates`: missing timers and explicitly +disabled settings default to **No**, including Enter and non-TTY reads. Only +`y`/`yes` (case-insensitive), or the existing explicit enable option, opts in. +Explicit CLI choices are not prompted again. Existing enabled or disabled timer +assets still refresh without changing their enablement; fresh installs remain +opt-in. Readiness, signature validation and persistent-data backup are unchanged. + +`auto_update_intent_test.go` executes the real main flows for update, rollback, +same-version reinstall and both menu actions, covering absent/disabled/enabled +timers, Enter, EOF, invalid/no/affirmative input and explicit CLI choices. +`root_install_sh_test.go` binds those paths to the shared choice, and +`build_release_assets_test.go` binds the signed published lifecycle rehearsal to +its new intent check. The rehearsal now retains only the boolean choice and +timer enablement/activity before and after upgrade and rollback, fails changed +or unavailable observations, and does not pass a disable flag to hide the bug. +Its Python tests execute the observer/comparator against intact, changed and +unreadable fixtures. These source proofs are not native installed acceptance; +the published containing installer and both actual lifecycle phases still need +their terminal observations. + ### Credential-safe Proxmox bootstrap (1 October 2026) Current PVE auto-registration metadata accepts the credential-free setup artifact: all command aliases use a private-file handoff and `downloadURL` equals the tokenless script URL. This replaces earlier requirements to embed setup tokens in commands/URLs. The older coherent server artifact is accepted read-only during upgrades, never executed. Host, type, canonical filename/URL, masked hint and live expiry remain required. Root-installer JSON parsing and registration pass secrets through descriptor/stdin input rather than Python/curl argv, and the setup response is no longer persisted as a plaintext /tmp diagnostic. No install source, API scope, trust exception, release selector or success condition is widened. diff --git a/install.sh b/install.sh index d345f3c7c..6fe81754d 100755 --- a/install.sh +++ b/install.sh @@ -699,6 +699,38 @@ safe_read_with_default() { return 0 } +# A version change is not consent to unattended updates. Keep the existing +# choice (including a disabled timer) and require an affirmative answer when +# offering updates on an installation without an enabled configuration. +offer_existing_auto_updates() { + if [[ "$AUTO_UPDATE_CHOICE_EXPLICIT" == "true" ]] || [[ "$ENABLE_AUTO_UPDATES" == "true" ]] || [[ "$IN_DOCKER" == "true" ]]; then + return 0 + fi + + local prompt_reason="" + if ! update_timer_exists; then + prompt_reason="missing" + elif [[ -f "$CONFIG_DIR/system.json" ]] && grep -Eq '"autoUpdateEnabled"[[:space:]]*:[[:space:]]*false' "$CONFIG_DIR/system.json"; then + prompt_reason="disabled" + fi + [[ -n "$prompt_reason" ]] || return 0 + + echo + if [[ "$prompt_reason" == "disabled" ]]; then + echo -e "${YELLOW}Auto-updates are currently disabled.${NC}" + else + echo "Automatic updates are not configured." + fi + echo "Pulse can automatically install stable updates daily (between 2-6 AM)" + echo "Leave this disabled to keep control of version changes and rollbacks." + local enable_updates="" + safe_read_with_default "Enable auto-updates? [y/N]: " enable_updates "n" + if [[ "$enable_updates" =~ ^([Yy]|[Yy][Ee][Ss])$ ]]; then + ENABLE_AUTO_UPDATES=true + fi + return 0 +} + wait_for_pulse_ready() { local pulse_url="$1" local retries="${2:-60}" @@ -4870,42 +4902,8 @@ main() { print_info "${action_word} version ${FORCE_VERSION}..." LATEST_RELEASE="${FORCE_VERSION}" - # Check if auto-updates should be offered when using --version - # Same logic as update/reinstall paths - if [[ "$AUTO_UPDATE_CHOICE_EXPLICIT" != "true" ]] && [[ "$ENABLE_AUTO_UPDATES" != "true" ]] && [[ "$IN_DOCKER" != "true" ]]; then - local should_ask_about_updates=false - local prompt_reason="" - - if ! update_timer_exists; then - # Timer doesn't exist - new feature - should_ask_about_updates=true - prompt_reason="new" - elif [[ -f "$CONFIG_DIR/system.json" ]]; then - # Timer exists, check if it's properly configured - if grep -q '"autoUpdateEnabled":\s*false' "$CONFIG_DIR/system.json" 2>/dev/null; then - should_ask_about_updates=true - prompt_reason="disabled" - fi - fi - - if [[ "$should_ask_about_updates" == "true" ]]; then - echo - if [[ "$prompt_reason" == "disabled" ]]; then - echo -e "${YELLOW}Auto-updates are currently disabled.${NC}" - echo "Would you like to enable automatic updates?" - else - echo -e "${YELLOW}New feature: Automatic updates!${NC}" - fi - echo "Pulse can automatically install stable updates daily (between 2-6 AM)" - echo "This keeps your installation secure and up-to-date." - safe_read_with_default "Enable auto-updates? [Y/n]: " enable_updates "y" - # Default to yes for this prompt since they're already updating - if [[ ! "$enable_updates" =~ ^[Nn]$ ]]; then - ENABLE_AUTO_UPDATES=true - fi - fi - fi - + offer_existing_auto_updates + # Detect the actual service name before trying to stop it SERVICE_NAME=$(detect_service_name) @@ -5079,43 +5077,7 @@ main() { print_info "${action_word} $target_version..." LATEST_RELEASE="$target_version" - # Check if auto-updates should be offered to the user - # Offer if: not already forced by flag, not in Docker, and either: - # 1. Timer doesn't exist (new feature), OR - # 2. Timer exists but autoUpdateEnabled is false (misconfigured) - if [[ "$AUTO_UPDATE_CHOICE_EXPLICIT" != "true" ]] && [[ "$ENABLE_AUTO_UPDATES" != "true" ]] && [[ "$IN_DOCKER" != "true" ]]; then - local should_ask_about_updates=false - local prompt_reason="" - - if ! update_timer_exists; then - # Timer doesn't exist - new feature - should_ask_about_updates=true - prompt_reason="new" - elif [[ -f "$CONFIG_DIR/system.json" ]]; then - # Timer exists, check if it's properly configured - if grep -q '"autoUpdateEnabled":\s*false' "$CONFIG_DIR/system.json" 2>/dev/null; then - should_ask_about_updates=true - prompt_reason="disabled" - fi - fi - - if [[ "$should_ask_about_updates" == "true" ]]; then - echo - if [[ "$prompt_reason" == "disabled" ]]; then - echo -e "${YELLOW}Auto-updates are currently disabled.${NC}" - echo "Would you like to enable automatic updates?" - else - echo -e "${YELLOW}New feature: Automatic updates!${NC}" - fi - echo "Pulse can automatically install stable updates daily (between 2-6 AM)" - echo "This keeps your installation secure and up-to-date." - safe_read_with_default "Enable auto-updates? [Y/n]: " enable_updates "y" - # Default to yes for this prompt since they're already updating - if [[ ! "$enable_updates" =~ ^[Nn]$ ]]; then - ENABLE_AUTO_UPDATES=true - fi - fi - fi + offer_existing_auto_updates if ! run_upgrade_readiness_preflight "$CURRENT_VERSION" "$LATEST_RELEASE"; then exit 1 @@ -5147,44 +5109,8 @@ main() { exit 0 ;; reinstall) - # Check if auto-updates should be offered to the user - # Offer if: not already forced by flag, not in Docker, and either: - # 1. Timer doesn't exist (new feature), OR - # 2. Timer exists but autoUpdateEnabled is false (misconfigured) - if [[ "$AUTO_UPDATE_CHOICE_EXPLICIT" != "true" ]] && [[ "$ENABLE_AUTO_UPDATES" != "true" ]] && [[ "$IN_DOCKER" != "true" ]]; then - local should_ask_about_updates=false - local prompt_reason="" - - if ! update_timer_exists; then - # Timer doesn't exist - new feature - should_ask_about_updates=true - prompt_reason="new" - elif [[ -f "$CONFIG_DIR/system.json" ]]; then - # Timer exists, check if it's properly configured - if grep -q '"autoUpdateEnabled":\s*false' "$CONFIG_DIR/system.json" 2>/dev/null; then - should_ask_about_updates=true - prompt_reason="disabled" - fi - fi - - if [[ "$should_ask_about_updates" == "true" ]]; then - echo - if [[ "$prompt_reason" == "disabled" ]]; then - echo -e "${YELLOW}Auto-updates are currently disabled.${NC}" - echo "Would you like to enable automatic updates?" - else - echo -e "${YELLOW}New feature: Automatic updates!${NC}" - fi - echo "Pulse can automatically install stable updates daily (between 2-6 AM)" - echo "This keeps your installation secure and up-to-date." - safe_read_with_default "Enable auto-updates? [Y/n]: " enable_updates "y" - # Default to yes for this prompt - if [[ ! "$enable_updates" =~ ^[Nn]$ ]]; then - ENABLE_AUTO_UPDATES=true - fi - fi - fi - + offer_existing_auto_updates + backup_existing stop_pulse_for_update create_user diff --git a/scripts/installtests/auto_update_intent_test.go b/scripts/installtests/auto_update_intent_test.go new file mode 100644 index 000000000..902f3eea6 --- /dev/null +++ b/scripts/installtests/auto_update_intent_test.go @@ -0,0 +1,151 @@ +package installtests + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// Exercise the real main flow and read-default wrapper, not a model of the +// prompt. External install mutations are stubs confined to each fixture. +func TestRootInstallExistingAutoUpdatesRequireAffirmativeChoice(t *testing.T) { + type scenario struct { + name, config, answer string + timer, enabled, eof, explicit, optIn, wantSetup bool + } + cases := []scenario{ + {name: "missing_timer_enter"}, + {name: "missing_timer_non_tty", eof: true}, + {name: "disabled_enter", timer: true, config: `{"autoUpdateEnabled":false}`}, + {name: "disabled_non_tty", timer: true, config: `{"autoUpdateEnabled":false}`, eof: true}, + {name: "disabled_no", timer: true, config: `{"autoUpdateEnabled":false}`, answer: "n"}, + {name: "invalid_answer", answer: "maybe"}, + {name: "enabled_preserved", timer: true, enabled: true, config: `{"autoUpdateEnabled":true}`}, + {name: "disabled_timer_preserved", timer: true, config: `{"autoUpdateEnabled":true}`}, + {name: "explicit_disable", explicit: true, answer: "y"}, + {name: "explicit_enable", explicit: true, optIn: true, wantSetup: true}, + {name: "affirmative_y", answer: "y", wantSetup: true}, + {name: "affirmative_yes", answer: "Yes", timer: true, config: `{"autoUpdateEnabled":false}`, wantSetup: true}, + } + for _, flow := range []string{"version_upgrade", "version_rollback", "version_reinstall", "menu_update", "menu_reinstall"} { + for _, tc := range cases { + t.Run(flow+"/"+tc.name, func(t *testing.T) { + dir := t.TempDir() + config := filepath.Join(dir, "system.json") + if tc.config != "" { + if err := os.WriteFile(config, []byte(tc.config), 0600); err != nil { + t.Fatal(err) + } + } + installer, err := filepath.Abs(filepath.Join("..", "..", "install.sh")) + if err != nil { + t.Fatal(err) + } + script := ` +source "$INSTALLER_UNDER_TEST" +CONFIG_DIR="$FIXTURE_DIR" +INSTALL_DIR="$FIXTURE_DIR/install" +CURRENT_VERSION=v6.4.5 +LATEST_RELEASE=v6.4.6 +FORCE_VERSION="" +case "$FLOW" in + version_upgrade) FORCE_VERSION=v6.4.6 ;; + version_rollback) FORCE_VERSION=v6.4.1 ;; + version_reinstall) FORCE_VERSION=v6.4.5 ;; +esac +AUTO_UPDATE_CHOICE_EXPLICIT="$EXPLICIT" +ENABLE_AUTO_UPDATES="$OPT_IN" +UPDATE_CHANNEL=stable +for fn in print_header check_root detect_os check_docker_environment backup_existing stop_pulse_for_update create_user download_pulse setup_directories setup_update_command ensure_systemd_service_installed install_systemd_service start_pulse; do + eval "$fn() { :; }" +done +check_proxmox_host() { return 1; } +check_existing_installation() { return 0; } +detect_service_name() { echo pulse; } +resolve_latest_release_tag_for_channel() { echo v6.4.6; } +read_configured_update_channel() { echo stable; } +curl() { :; } +timeout() { shift; "$@"; } +run_upgrade_readiness_preflight() { return 0; } +update_timer_exists() { [[ "$TIMER" == true ]]; } +refresh_auto_updates() { echo REFRESH; } +setup_auto_updates() { + echo SETUP + printf '{"autoUpdateEnabled":true}' > "$CONFIG_DIR/system.json" + TIMER=true + TIMER_ENABLED=true +} +create_marker_file() { echo INSTALL_FINISHED; } +print_completion() { printf 'TIMER_ENABLED=%s\n' "$TIMER_ENABLED"; } +safe_read() { + if [[ "$1" == "Select option "* ]]; then + local selection=1 + [[ "$FLOW" == menu_reinstall ]] && selection=2 + printf -v "$2" '%s' "$selection" + else + echo AUTO_UPDATE_PROMPT + [[ "$READ_EOF" == true ]] && return 1 + printf -v "$2" '%s' "$ANSWER" + fi +} +main +` + cmd := exec.Command("bash", "-c", script) + cmd.Env = append(os.Environ(), "INSTALLER_UNDER_TEST="+installer, "FIXTURE_DIR="+dir, + "FLOW="+flow, "TIMER="+fmt.Sprint(tc.timer), "TIMER_ENABLED="+fmt.Sprint(tc.enabled), + "EXPLICIT="+fmt.Sprint(tc.explicit), "OPT_IN="+fmt.Sprint(tc.optIn), + "READ_EOF="+fmt.Sprint(tc.eof), "ANSWER="+tc.answer) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("main: %v\n%s", err, out) + } + if !strings.Contains(string(out), "INSTALL_FINISHED") { + t.Fatalf("install did not complete:\n%s", out) + } + if got := strings.Contains(string(out), "\nSETUP\n"); got != tc.wantSetup { + t.Fatalf("auto-update setup=%v, want %v:\n%s", got, tc.wantSetup, out) + } + if !tc.wantSetup { + got, readErr := os.ReadFile(config) + if tc.config == "" { + if !os.IsNotExist(readErr) { + t.Fatalf("absent config was changed: %s, %v", got, readErr) + } + } else if readErr != nil || string(got) != tc.config { + t.Fatalf("existing choice was changed: %s, %v", got, readErr) + } + if !strings.Contains(string(out), "TIMER_ENABLED="+fmt.Sprint(tc.enabled)) { + t.Fatalf("timer intent changed:\n%s", out) + } + if tc.timer && !strings.Contains(string(out), "REFRESH") { + t.Fatalf("existing helper was not refreshed:\n%s", out) + } + } + if (tc.explicit || tc.enabled) && strings.Contains(string(out), "AUTO_UPDATE_PROMPT") { + t.Fatalf("existing/explicit choice was re-prompted:\n%s", out) + } + }) + } + } +} + +func TestRootInstallSafeReadNonTTYDefaultsToNoAutoUpdates(t *testing.T) { + setsid, err := exec.LookPath("setsid") + if err != nil { + t.Skip("setsid unavailable for a controlling-terminal-free read") + } + script := `set -euo pipefail +print_info() { :; } +` + extractRootInstallShellFunction(t, "safe_read") + "\n" + extractRootInstallShellFunction(t, "safe_read_with_default") + ` +answer="" +safe_read_with_default "Enable auto-updates? [y/N]: " answer "n" +[[ "$answer" == n ]] +` + out, err := exec.Command(setsid, "bash", "-c", script).CombinedOutput() + if err != nil { + t.Fatalf("non-TTY fallback: %v\n%s", err, out) + } +} diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index 07c41c0e1..86d01a2e4 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -144,6 +144,30 @@ func TestBuildReleaseUsesV6InstallScripts(t *testing.T) { } } +// A no-flag, signed published-installer rehearsal must catch re-enabling after +// a version change. The install smoke's explicit disable flag alone cannot. +func TestPublishedLifecycleRehearsalPreservesAutoUpdateChoice(t *testing.T) { + content, err := os.ReadFile(repoFile("scripts", "release_lifecycle_rehearsal.sh")) + if err != nil { + t.Fatal(err) + } + for _, required := range []string{ + `auto_update_snapshot > "${WORK_DIR}/state/auto-updates.baseline.tsv"`, + `check_auto_update_intent upgrade || true`, + `check_auto_update_intent rollback || true`, + `cexec '/bin/update --version "$TARGET"'`, + } { + if !strings.Contains(string(content), required) { + t.Fatalf("published installer lifecycle proof missing %s", required) + } + } + for _, line := range strings.Split(string(content), "\n") { + if strings.Contains(line, "/bin/update --version") && strings.Contains(line, "--disable-auto-updates") { + t.Fatal("lifecycle proof must observe installer consent, not bypass it") + } + } +} + func TestSecurityScanRevalidatesLatestStableDelivery(t *testing.T) { content, err := os.ReadFile(repoFile(".github", "workflows", "security-scan.yml")) if err != nil { diff --git a/scripts/installtests/root_install_sh_test.go b/scripts/installtests/root_install_sh_test.go index a19044b49..b19400c6e 100644 --- a/scripts/installtests/root_install_sh_test.go +++ b/scripts/installtests/root_install_sh_test.go @@ -1480,6 +1480,21 @@ func TestRootInstallScriptUpdateFlowsRefreshExistingAutoUpdateAssets(t *testing. } } +// The tested --version, menu-update and menu-reinstall paths must all use the +// same affirmative-only prompt; a new inline default-yes branch is unsafe. +func TestRootInstallExistingFlowsShareExplicitAutoUpdateChoice(t *testing.T) { + main := extractRootInstallShellFunction(t, "main") + if got := strings.Count(main, "offer_existing_auto_updates"); got != 3 { + t.Fatalf("expected three existing-install consent calls, found %d", got) + } + if strings.Contains(main, `Enable auto-updates? [Y/n]`) || strings.Contains(main, `Re-enable auto-updates? [Y/n]`) { + t.Fatal("existing install must not default to enabling auto-updates") + } + if !strings.Contains(main, `safe_read_with_default "Enable auto-updates? [y/N]: " enable_updates "n"`) { + t.Fatal("fresh-install consent must remain opt-in") + } +} + // Regression test for #1526 (and the earlier #1396): when the installer is piped // to bash (curl ... | bash) there is no source file, so BASH_SOURCE is unset. // The "am I being sourced?" guard must default the lookup or `set -u` aborts the diff --git a/scripts/release_lifecycle_rehearsal.sh b/scripts/release_lifecycle_rehearsal.sh index b9ef5e5b5..100fc3388 100755 --- a/scripts/release_lifecycle_rehearsal.sh +++ b/scripts/release_lifecycle_rehearsal.sh @@ -391,6 +391,59 @@ assert_runtime() { fi } +# Only bounded, non-secret intent fields leave the disposable install. A failed +# read is not an absent timer or a disabled setting. +auto_update_snapshot() { + cexec 'set -euo pipefail +config=absent +if [[ -e "$DATA_DIR/system.json" ]]; then + config=$(jq -er '\''if type != "object" then error("invalid system settings") + elif has("autoUpdateEnabled") then + if .autoUpdateEnabled == true then "enabled" + elif .autoUpdateEnabled == false then "disabled" + else error("invalid auto-update choice") end + else "absent" end'\'' "$DATA_DIR/system.json") +fi +load=$(systemctl show -p LoadState --value pulse-update.timer) +case "$load" in + not-found) enabled=absent; active=absent ;; + loaded) + enabled=$(systemctl is-enabled pulse-update.timer || true) + active=$(systemctl is-active pulse-update.timer || true) + case "$enabled" in + enabled|enabled-runtime|disabled|masked|masked-runtime|static|indirect|linked|linked-runtime|generated|transient) ;; + *) exit 1 ;; + esac + case "$active" in + active|inactive|failed|activating|deactivating|reloading) ;; + *) exit 1 ;; + esac ;; + *) exit 1 ;; +esac +printf "%s\t%s\t%s\n" "$config" "$enabled" "$active"' "DATA_DIR=${DATA_DIR}" +} + +check_auto_update_intent() { + local label="$1" current before_config before_enabled before_active config enabled active + if [[ ! -s "${WORK_DIR}/state/auto-updates.baseline.tsv" ]] \ + || ! current=$(auto_update_snapshot); then + note_failure "${label}: auto-update intent could not be read or has no baseline" + return 1 + fi + IFS=$'\t' read -r before_config before_enabled before_active < "${WORK_DIR}/state/auto-updates.baseline.tsv" + IFS=$'\t' read -r config enabled active <<< "$current" + # A newly persisted false is equivalent to an absent opt-in, not permission + # to enable the timer. Preserve enabled and masked timer choices exactly. + if [[ "$config" != "$before_config" \ + && ! ( "$config" != enabled && "$before_config" != enabled ) ]] \ + || [[ "$enabled" != "$before_enabled" || "$active" != "$before_active" ]]; then + note_failure "${label}: auto-update choice or timer enablement/activity changed" + return 1 + fi + printf '%s\n' "$current" > "${WORK_DIR}/state/auto-updates.${label}.tsv" + PHASE_UNITS="${PHASE_UNITS}; auto-updates ${config}/${enabled}/${active} preserved" +} + api_status() { # api_status METHOD PATH [auth: none|token|basic] [body] cexec 'args=(-sS -o /dev/null -w "%{http_code}" -X "$METHOD") @@ -626,6 +679,9 @@ phase_seed() { note_failure "could not record ${DATA_DIR} (or it has no .encryption.key)" PHASE_DATADIR="not recorded" fi + if ! auto_update_snapshot > "${WORK_DIR}/state/auto-updates.baseline.tsv"; then + note_failure "could not record the installed auto-update choice and timer state" + fi record_phase "2. seed" "$FROM_TAG" "-" "-" "$PHASE_SETTINGS" "$PHASE_DATADIR" "-" } @@ -650,6 +706,7 @@ phase_upgrade() { log "Phase 3: upgrade with /bin/update --version ${TO_TAG}" run_updater "$TO_TAG" upgrade || true assert_runtime "$TO_TAG" + check_auto_update_intent upgrade || true check_settings upgrade || true check_datadir upgrade || true record_phase "3. upgrade (/bin/update)" "$TO_TAG" "$PHASE_VERSION" "$PHASE_HEALTH" "$PHASE_SETTINGS" "$PHASE_DATADIR" "$PHASE_UNITS" @@ -660,6 +717,7 @@ phase_rollback() { log "Phase 4: roll back with /bin/update --version ${FROM_TAG}" run_updater "$FROM_TAG" rollback || true assert_runtime "$FROM_TAG" + check_auto_update_intent rollback || true check_settings rollback || true check_datadir rollback || true record_phase "4. rollback (/bin/update)" "$FROM_TAG" "$PHASE_VERSION" "$PHASE_HEALTH" "$PHASE_SETTINGS" "$PHASE_DATADIR" "$PHASE_UNITS" diff --git a/scripts/tests/test_release_lifecycle_rehearsal.py b/scripts/tests/test_release_lifecycle_rehearsal.py index 9af5eb30f..b778c40c2 100644 --- a/scripts/tests/test_release_lifecycle_rehearsal.py +++ b/scripts/tests/test_release_lifecycle_rehearsal.py @@ -5,6 +5,7 @@ from __future__ import annotations import importlib.util import json +import os import re import subprocess import sys @@ -150,7 +151,7 @@ class HarnessContractTest(unittest.TestCase): def test_every_post_change_phase_checks_identity_health_settings_and_data(self) -> None: for phase in ("phase_upgrade", "phase_rollback"): body = self.harness.split(f"{phase}() {{", 1)[1].split("\n}\n", 1)[0] - for check in ("assert_runtime", "check_settings", "check_datadir", "record_phase"): + for check in ("assert_runtime", "check_auto_update_intent", "check_settings", "check_datadir", "record_phase"): with self.subTest(phase=phase, check=check): self.assertIn(check, body) @@ -176,6 +177,98 @@ class HarnessContractTest(unittest.TestCase): self.assertEqual(result.returncode, 2, result.stdout + result.stderr) +class AutoUpdateIntentTest(unittest.TestCase): + """Execute the real observer/comparator, with guest systemctl observations.""" + + def check(self, baseline: str | None, config: object = None, *, + load: str = "not-found", enabled: str = "disabled", + active: str = "inactive") -> subprocess.CompletedProcess: + harness = HARNESS_PATH.read_text(encoding="utf-8") + functions = "\n".join( + name + "() {" + harness.split(name + "() {", 1)[1].split("\n}\n", 1)[0] + "\n}" + for name in ("auto_update_snapshot", "check_auto_update_intent") + ) + script = r'''set -euo pipefail +PHASE_UNITS="pulse active/enabled" +note_failure() { echo "::error::$*"; } +systemctl() { + case "$*" in + "show -p LoadState --value pulse-update.timer") echo "$LOAD" ;; + "is-enabled pulse-update.timer") echo "$ENABLED"; [[ "$ENABLED" == enabled ]] ;; + "is-active pulse-update.timer") echo "$ACTIVE"; [[ "$ACTIVE" == active ]] ;; + *) return 1 ;; + esac +} +export -f systemctl +cexec() { bash -c "$1"; } +''' + functions + '\ncheck_auto_update_intent rollback\nprintf "%s\\n" "$PHASE_UNITS"\n' + with tempfile.TemporaryDirectory() as tmp: + work = Path(tmp) + (work / "state").mkdir() + data = work / "data" + data.mkdir() + if baseline is not None: + (work / "state" / "auto-updates.baseline.tsv").write_text(baseline + "\n", encoding="utf-8") + if config is not None: + (data / "system.json").write_text( + config if isinstance(config, str) else json.dumps(config), encoding="utf-8") + return subprocess.run( + ["bash", "-c", script], capture_output=True, text=True, check=False, + env={**os.environ, "WORK_DIR": str(work), "DATA_DIR": str(data), + "LOAD": load, "ENABLED": enabled, "ACTIVE": active}, + ) + + def test_absent_timer_and_unset_choice_stay_absent(self) -> None: + result = self.check("absent\tabsent\tabsent") + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + self.assertIn("auto-updates absent/absent/absent preserved", result.stdout) + + def test_disabled_enabled_and_masked_choices_are_preserved(self) -> None: + for config, enabled, active in ((False, "disabled", "inactive"), + (True, "enabled", "active"), + (False, "masked", "inactive")): + with self.subTest(config=config, timer=enabled): + result = self.check(f"{'enabled' if config else 'disabled'}\t{enabled}\t{active}", + {"autoUpdateEnabled": config}, load="loaded", enabled=enabled, active=active) + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + + def test_persisted_false_is_not_a_new_opt_in(self) -> None: + result = self.check("absent\tabsent\tabsent", {"autoUpdateEnabled": False}) + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + + def test_config_timer_or_activity_changes_fail(self) -> None: + for config, enabled, active in ((True, "disabled", "inactive"), + (False, "enabled", "inactive"), + (False, "disabled", "active")): + with self.subTest(config=config, timer=enabled, active=active): + result = self.check("disabled\tdisabled\tinactive", {"autoUpdateEnabled": config}, + load="loaded", enabled=enabled, active=active) + self.assertEqual(1, result.returncode, result.stdout + result.stderr) + self.assertIn("auto-update choice or timer enablement/activity changed", result.stdout) + + def test_enabled_choice_cannot_be_silently_disabled(self) -> None: + result = self.check("enabled\tenabled\tactive", {"autoUpdateEnabled": False}, + load="loaded", enabled="enabled", active="active") + self.assertEqual(1, result.returncode, result.stdout + result.stderr) + + def test_unreadable_choice_or_timer_is_not_assumed_disabled(self) -> None: + for config, load, enabled, active in (("not json", "not-found", "disabled", "inactive"), + ({"autoUpdateEnabled": "true"}, "not-found", "disabled", "inactive"), + ({"autoUpdateEnabled": None}, "not-found", "disabled", "inactive"), + (None, "", "disabled", "inactive"), + (None, "loaded", "", "inactive"), + (None, "loaded", "disabled", "unknown")): + with self.subTest(config=config, load=load, enabled=enabled, active=active): + result = self.check("absent\tabsent\tabsent", config, load=load, enabled=enabled, active=active) + self.assertEqual(1, result.returncode, result.stdout + result.stderr) + self.assertIn("could not be read", result.stdout) + + def test_missing_baseline_cannot_pass(self) -> None: + result = self.check(None) + self.assertEqual(1, result.returncode, result.stdout + result.stderr) + self.assertIn("has no baseline", result.stdout) + + GOOD_SNAPSHOT = { "auth": {"api_token": "200", "password": "200", "requiresAuth": True, "unauthenticated": "401"}, "node": {