mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-25 21:20:00 +00:00
6dc43d298c
The server installer downloaded the release archive and extracted it under /tmp. Debian 13 and many LXC templates mount /tmp as tmpfs, whose pages are charged to the container's memory cgroup. A v6.5.0 archive is ~280 MB and extracts to ~750 MB, so an unattended update on a 1 GiB LXC filled the cgroup with ~820 MB of shmem; the kernel OOM-killed the container's systemd and Proxmox stopped the container mid-update. Download and extract into owner-only hidden directories under the install directory instead. It is always writable from the hardened pulse-update.service sandbox, it is where the staged binary already lives, and the disk headroom preflight now measures the filesystem the update actually stages on. The Go updater already prefers its data directory over /tmp for the same reason. Unlike /tmp, the install filesystem keeps an interrupted run's staging across reboots. Staging setup now removes stale staging directories that match only the exact owner-only names, never symlinks or files, and only once their ctime is over six hours old: install.sh has no update lock, so age is what proves no live run still owns them. Every recursive pulse chown of the install directory now goes through one helper that skips staging, so the payload root still reads is never handed to the service account and the ~750 MB tree is not rewritten for nothing. The "Failed to print table: Broken pipe" line in the report was systemctl list-unit-files | grep -q in v6.5.0's service discovery; main already reads that inventory in full, and it was not the failure. Contract-Neutral: Defect fix inside the existing private server-installer inputs and update-space boundaries; staging moves from tmpfs-prone /tmp to the install filesystem with no public contract change
361 lines
16 KiB
Python
361 lines
16 KiB
Python
#!/usr/bin/env python3
|
|
"""Actual installer staging functions, confined to ordinary-user fixtures.
|
|
|
|
Transport and service boundaries are doubles; signature verification is real.
|
|
Directory modes establish the staging boundary, not a native root/LXC proof.
|
|
"""
|
|
|
|
import os
|
|
from pathlib import Path
|
|
import stat
|
|
import subprocess
|
|
import tempfile
|
|
import unittest
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
INSTALLER = Path(os.environ.get("PULSE_INSTALLER_UNDER_TEST", ROOT / "install.sh"))
|
|
|
|
HARNESS = r'''
|
|
source "$INSTALLER"
|
|
print_info() { :; }
|
|
print_success() { :; }
|
|
print_warn() { printf '%s\n' "$*" >&2; }
|
|
print_error() { printf '%s\n' "$*" >&2; }
|
|
mktemp() {
|
|
[[ "$FAULT" != allocation ]] || return 1
|
|
local template="${@: -1}" created
|
|
local -a args=("$@")
|
|
printf '%s\n' "$template" >> "$FIXTURE/templates"
|
|
args[${#args[@]}-1]="$FIXTURE/shared/${template##*/}"
|
|
created=$(command mktemp "${args[@]}") || return 1
|
|
printf '%s\n' "$created" >> "$FIXTURE/created"
|
|
if [[ -d "$created" ]]; then
|
|
stat -c '%a' "$created" >> "$FIXTURE/directory-modes"
|
|
fi
|
|
printf '%s\n' "$created"
|
|
}
|
|
timeout() { shift; "$@"; }
|
|
sleep() { :; }
|
|
curl() {
|
|
local url="${@: -1}"
|
|
printf '%s\n' "$url" >> "$FIXTURE/transport"
|
|
case "$url" in
|
|
https://github.com/rcourtman/Pulse/releases/download/v6.6.0/install.sh)
|
|
cat "$FIXTURE/installer-input" || return 1
|
|
[[ "$FAULT" != script_interrupt ]] || kill -TERM "$$"
|
|
[[ "$FAULT" != script_transport ]] ;;
|
|
https://github.com/rcourtman/Pulse/releases/download/v6.6.0/install.sh.sshsig)
|
|
cat "$FIXTURE/installer-input.sig" || return 1
|
|
[[ "$FAULT" != signature_transport ]] ;;
|
|
*) return 97 ;;
|
|
esac
|
|
}
|
|
resolve_install_script_download_url() {
|
|
[[ "$FAULT" != metadata ]] || return 1
|
|
echo https://github.com/rcourtman/Pulse/releases/download/v6.6.0/install.sh
|
|
}
|
|
PINNED_RELEASE_SSH_PUBLIC_KEY=$(cat "$FIXTURE/signing-key.pub")
|
|
if [[ "$FAULT" == verifier ]]; then
|
|
command() {
|
|
[[ "$*" != '-v ssh-keygen' ]] || return 1
|
|
builtin command "$@"
|
|
}
|
|
fi
|
|
INSTALL_DIR="$FIXTURE/install"
|
|
BUILD_FROM_SOURCE_MARKER="$INSTALL_DIR/BUILD_FROM_SOURCE"
|
|
CURRENT_VERSION=v6.5.0
|
|
LATEST_RELEASE=v6.6.0
|
|
detect_pulse_architecture() { echo amd64; }
|
|
ensure_update_disk_headroom() { printf '%s\n' "$1" > "$FIXTURE/headroom-path"; }
|
|
resolve_target_release() { :; }
|
|
download_release_archive() {
|
|
printf '%s\n' "$3" > "$FIXTURE/archive-path"
|
|
printf 'archive fixture' > "$3"
|
|
printf 'signature fixture' > "${3}.sshsig"
|
|
[[ "$FAULT" != archive_transport ]]
|
|
}
|
|
run_upgrade_readiness_preflight() { [[ "$FAULT" != readiness ]]; }
|
|
install_pulse_archive() {
|
|
echo INSTALL >> "$FIXTURE/calls"
|
|
[[ "$FAULT" != archive_admission ]]
|
|
}
|
|
# Exercise the complete production bootstrap too; no pct, package, service,
|
|
# registration, bridge or guest command below can reach a real host/guest.
|
|
print_header() { :; }
|
|
cleanup_stale_sensor_proxy_mounts() { :; }
|
|
safe_read_with_default() { printf -v "$2" '%s' "$3"; }
|
|
detect_network_bridges() { echo vmbr0; }
|
|
ip() { printf 'default via 198.51.100.1 dev vmbr0\n'; }
|
|
ensure_debian_template() { DEBIAN_TEMPLATE=fixture.tar.zst; }
|
|
pvesh() { echo 600; }
|
|
qm() { return 1; }
|
|
pvesm() { printf 'Name Type Status Total Used Available\nlocal dir active 99999999 1 99999998\n'; }
|
|
pveam() { printf 'NAME SIZE\nlocal:vztmpl/fixture.tar.zst 1\n'; }
|
|
auto_register_pve_node() { :; }
|
|
wait_for_pulse_ready() { :; }
|
|
pct() {
|
|
case "$1" in
|
|
create|start|stop|destroy) return 0 ;;
|
|
push)
|
|
printf 'PUSH=%s\n' "$4" >> "$FIXTURE/calls"
|
|
[[ -f "$3" ]] || return 1
|
|
[[ "$FAULT" != script_copy || "$4" != /tmp/install.sh ]] || return 1
|
|
if [[ "$4" == /tmp/install.sh ]]; then
|
|
cp "$3" "$FIXTURE/copied-script"
|
|
fi
|
|
return 0 ;;
|
|
exec)
|
|
if [[ "$*" == *'hostname -I'* ]]; then
|
|
echo 198.51.100.2
|
|
elif [[ "$*" == *'bash /tmp/install.sh'* ]]; then
|
|
echo EXECUTE >> "$FIXTURE/calls"
|
|
fi
|
|
return 0 ;;
|
|
*) return 97 ;;
|
|
esac
|
|
}
|
|
umask "$MASK"
|
|
case "$FLOW" in
|
|
reserve)
|
|
path=$(create_temp_archive_path "$FIXTURE/shared/pulse-v6.6.0-linux-amd64")
|
|
printf '%s\n' "$path" > "$FIXTURE/archive-path"
|
|
infer_release_from_archive_name "$path" > "$FIXTURE/inferred"
|
|
;;
|
|
cleanup)
|
|
path=$(create_temp_archive_path "$FIXTURE/shared/pulse-v6.6.0-linux-amd64")
|
|
printf '%s\n' "$path" > "$FIXTURE/archive-path"
|
|
printf archive > "$path"
|
|
printf signature > "${path}.sshsig"
|
|
[[ "$FAULT" != extra_file ]] || touch "$(dirname "$path")/unowned"
|
|
cleanup_temp_archive_path "$path"
|
|
;;
|
|
download) download_pulse ;;
|
|
override)
|
|
ARCHIVE_OVERRIDE="$FIXTURE/pulse-v6.6.0-linux-amd64.tar.gz"
|
|
FORCE_VERSION=v6.6.0
|
|
download_pulse
|
|
;;
|
|
prefetch)
|
|
prefetched=""
|
|
prefetch_pulse_archive_for_container prefetched
|
|
printf '%s\n' "$prefetched" > "$FIXTURE/returned"
|
|
;;
|
|
bootstrap)
|
|
script_source=""
|
|
container_script_temp_dir=""
|
|
trap 'cleanup_container_install_inputs "${container_script_temp_dir:-}" "" false' EXIT
|
|
download_container_installer script_source container_script_temp_dir
|
|
printf '%s\n' "$script_source" > "$FIXTURE/returned"
|
|
cp "$script_source" "$FIXTURE/copied-script"
|
|
;;
|
|
interrupted_bootstrap)
|
|
script_source=""
|
|
container_script_temp_dir=""
|
|
trap 'cleanup_container_install_inputs "${container_script_temp_dir:-}" "" false' EXIT
|
|
trap 'exit 143' TERM
|
|
download_container_installer script_source container_script_temp_dir
|
|
kill -TERM "$$"
|
|
;;
|
|
container_cleanup)
|
|
container_archive_source=""
|
|
prefetch_pulse_archive_for_container container_archive_source
|
|
script_source=""
|
|
container_script_temp_dir=""
|
|
trap 'cleanup_container_install_inputs "${container_script_temp_dir:-}" "$container_archive_source" true' EXIT
|
|
download_container_installer script_source container_script_temp_dir
|
|
exit 42
|
|
;;
|
|
connected_container) create_lxc_container ;;
|
|
local_container)
|
|
ARCHIVE_OVERRIDE="$FIXTURE/pulse-v6.6.0-linux-amd64.tar.gz"
|
|
create_lxc_container
|
|
;;
|
|
esac
|
|
'''
|
|
|
|
|
|
class ServerInstallerTemporaryInputsTest(unittest.TestCase):
|
|
def exercise(self, flow, fault="", mask="000"):
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
fixture = Path(temporary)
|
|
(fixture / "shared").mkdir(mode=0o777)
|
|
(fixture / "shared").chmod(0o1777)
|
|
(fixture / "install").mkdir()
|
|
(fixture / "install/BUILD_FROM_SOURCE").write_text("old marker")
|
|
(fixture / "installer-input").write_text("#!/bin/bash\n# inert signed fixture\n")
|
|
key = fixture / "signing-key"
|
|
subprocess.run(["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", str(key)],
|
|
check=True, capture_output=True)
|
|
subprocess.run(["ssh-keygen", "-q", "-Y", "sign", "-f", str(key),
|
|
"-n", "pulse-install", str(fixture / "installer-input")],
|
|
check=True, capture_output=True)
|
|
if fault == "bad_signature":
|
|
(fixture / "installer-input").write_text("unsigned changed fixture\n")
|
|
if fault == "missing_signature":
|
|
(fixture / "installer-input.sig").unlink()
|
|
override = fixture / "pulse-v6.6.0-linux-amd64.tar.gz"
|
|
override.write_bytes(b"caller archive")
|
|
Path(str(override) + ".sshsig").write_bytes(b"caller signature")
|
|
env = {**os.environ, "INSTALLER": str(INSTALLER), "FIXTURE": str(fixture),
|
|
"FLOW": flow, "FAULT": fault, "MASK": mask}
|
|
command = ["bash", "-c", HARNESS]
|
|
if flow == "local_container":
|
|
command.append(str(fixture / "installer-input"))
|
|
result = subprocess.run(command, env=env, text=True,
|
|
capture_output=True, timeout=15)
|
|
observed = {}
|
|
for name in ("archive-path", "returned", "inferred", "calls", "transport", "directory-modes",
|
|
"templates", "headroom-path"):
|
|
p = fixture / name
|
|
observed[name] = p.read_text().splitlines() if p.exists() else []
|
|
archive_path = Path(observed["archive-path"][0]) if observed["archive-path"] else None
|
|
observed["archive_parent_is_private"] = bool(archive_path and archive_path.parent != fixture / "shared"
|
|
and archive_path.parent.is_dir() and stat.S_IMODE(archive_path.parent.stat().st_mode) == 0o700)
|
|
observed["shared_entries"] = sorted(p.name for p in (fixture / "shared").iterdir())
|
|
observed["unowned"] = sorted(str(p.relative_to(fixture / "shared"))
|
|
for p in (fixture / "shared").rglob("unowned"))
|
|
observed["copied"] = (fixture / "copied-script").read_bytes() if (fixture / "copied-script").exists() else None
|
|
observed["caller-script"] = (fixture / "installer-input").read_bytes() if (fixture / "installer-input").exists() else None
|
|
observed["override"] = (override.read_bytes(), Path(str(override) + ".sshsig").read_bytes())
|
|
return result, observed
|
|
|
|
def test_archive_and_sidecar_reserve_a_private_parent(self):
|
|
for mask in ("000", "022", "077"):
|
|
with self.subTest(umask=mask):
|
|
r, o = self.exercise("reserve", mask=mask)
|
|
self.assertEqual(r.returncode, 0, r.stderr)
|
|
self.assertTrue(o["archive_parent_is_private"], o)
|
|
self.assertEqual(o["inferred"], ["v6.6.0"])
|
|
|
|
def test_allocation_failure_cannot_return_an_unreserved_path(self):
|
|
r, o = self.exercise("reserve", "allocation")
|
|
self.assertNotEqual(r.returncode, 0)
|
|
self.assertEqual(o["archive-path"], [])
|
|
self.assertEqual(o["shared_entries"], [])
|
|
self.assertEqual(o["directory-modes"], [])
|
|
|
|
def test_cleanup_removes_archive_sidecar_and_owned_empty_directory(self):
|
|
r, o = self.exercise("cleanup")
|
|
self.assertEqual(r.returncode, 0, r.stderr)
|
|
self.assertEqual(o["shared_entries"], [])
|
|
|
|
def test_bootstrap_staging_is_private_even_with_a_permissive_umask(self):
|
|
for mask in ("000", "022", "077"):
|
|
with self.subTest(umask=mask):
|
|
r, o = self.exercise("bootstrap", mask=mask)
|
|
self.assertEqual(r.returncode, 0, r.stderr)
|
|
self.assertEqual(o["directory-modes"], ["700"])
|
|
|
|
def test_cleanup_never_recursively_erases_an_unexpected_file(self):
|
|
r, o = self.exercise("cleanup", "extra_file")
|
|
self.assertEqual(r.returncode, 0, r.stderr)
|
|
self.assertEqual(len(o["unowned"]), 1, o)
|
|
|
|
def test_download_success_and_each_failure_clean_owned_inputs(self):
|
|
for fault in ("", "archive_transport", "readiness", "archive_admission", "allocation"):
|
|
with self.subTest(fault=fault):
|
|
r, o = self.exercise("download", fault)
|
|
self.assertEqual(r.returncode, 1 if fault else 0, r.stderr)
|
|
self.assertEqual(o["shared_entries"], [], o)
|
|
self.assertEqual(o["calls"], ["INSTALL"] if fault in ("", "archive_admission") else [])
|
|
|
|
def test_download_stages_archive_on_the_install_filesystem(self):
|
|
# /tmp is tmpfs on Debian 13 and many LXC templates, where a staged
|
|
# release is charged to the container's memory limit (#2785).
|
|
r, o = self.exercise("download")
|
|
self.assertEqual(r.returncode, 0, r.stderr)
|
|
self.assertEqual(len(o["templates"]), 1, o)
|
|
template = Path(o["templates"][0])
|
|
self.assertEqual(template.parent.name, "install", o)
|
|
self.assertEqual(template.name, ".pulse-v6.6.0-linux-amd64-XXXXXX")
|
|
self.assertEqual(Path(o["headroom-path"][0]), template.parent)
|
|
self.assertEqual(Path(o["archive-path"][0]).name, "pulse-v6.6.0-linux-amd64.tar.gz")
|
|
|
|
def test_explicit_archive_and_sidecar_are_never_owned_or_deleted(self):
|
|
for fault in ("", "readiness", "archive_admission"):
|
|
with self.subTest(fault=fault):
|
|
r, o = self.exercise("override", fault)
|
|
self.assertEqual(r.returncode, 1 if fault else 0, r.stderr)
|
|
self.assertEqual(o["override"], (b"caller archive", b"caller signature"))
|
|
self.assertEqual(o["shared_entries"], [])
|
|
|
|
def test_prefetch_retains_private_archive_until_handoff(self):
|
|
r, o = self.exercise("prefetch")
|
|
self.assertEqual(r.returncode, 0, r.stderr)
|
|
self.assertTrue(o["archive_parent_is_private"], o)
|
|
self.assertEqual(o["returned"], o["archive-path"])
|
|
|
|
def test_prefetch_failure_releases_its_private_inputs(self):
|
|
r, o = self.exercise("prefetch", "archive_transport")
|
|
self.assertNotEqual(r.returncode, 0)
|
|
self.assertEqual(o["shared_entries"], [])
|
|
|
|
def test_bootstrap_checks_real_signature_before_copy(self):
|
|
r, o = self.exercise("bootstrap")
|
|
self.assertEqual(r.returncode, 0, r.stderr)
|
|
self.assertEqual(o["copied"], b"#!/bin/bash\n# inert signed fixture\n")
|
|
self.assertEqual(o["caller-script"], b"#!/bin/bash\n# inert signed fixture\n")
|
|
self.assertEqual(o["transport"], [
|
|
"https://github.com/rcourtman/Pulse/releases/download/v6.6.0/install.sh",
|
|
"https://github.com/rcourtman/Pulse/releases/download/v6.6.0/install.sh.sshsig"])
|
|
self.assertEqual(o["shared_entries"], [])
|
|
|
|
def test_bootstrap_fails_closed_and_cleans_every_owned_input(self):
|
|
for fault in ("metadata", "verifier", "allocation", "script_transport",
|
|
"signature_transport", "missing_signature", "bad_signature"):
|
|
with self.subTest(fault=fault):
|
|
r, o = self.exercise("bootstrap", fault)
|
|
self.assertNotEqual(r.returncode, 0, r.stdout + r.stderr)
|
|
self.assertIsNone(o["copied"])
|
|
self.assertEqual(o["shared_entries"], [], o)
|
|
if fault in ("metadata", "verifier", "allocation"):
|
|
self.assertEqual(o["transport"], [])
|
|
|
|
def test_interrupted_bootstrap_cleans_inputs_without_a_copy(self):
|
|
r, o = self.exercise("interrupted_bootstrap")
|
|
self.assertEqual(r.returncode, 143, r.stderr)
|
|
self.assertIsNone(o["copied"])
|
|
self.assertEqual(o["shared_entries"], [])
|
|
|
|
def test_container_failure_cleans_both_owned_staging_directories(self):
|
|
r, o = self.exercise("container_cleanup")
|
|
self.assertEqual(r.returncode, 42, r.stderr)
|
|
self.assertEqual(o["shared_entries"], [], o)
|
|
|
|
def test_production_container_bootstrap_copies_only_the_verified_installer(self):
|
|
r, o = self.exercise("connected_container")
|
|
self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
|
|
self.assertEqual(o["copied"], b"#!/bin/bash\n# inert signed fixture\n")
|
|
self.assertIn("EXECUTE", o["calls"])
|
|
self.assertEqual(o["shared_entries"], [])
|
|
|
|
def test_production_container_never_copies_invalid_or_unavailable_signed_input(self):
|
|
for fault in ("bad_signature", "script_transport", "signature_transport", "missing_signature", "script_interrupt"):
|
|
with self.subTest(fault=fault):
|
|
r, o = self.exercise("connected_container", fault)
|
|
self.assertEqual(r.returncode, 1, r.stdout + r.stderr)
|
|
self.assertIsNone(o["copied"])
|
|
self.assertNotIn("EXECUTE", o["calls"])
|
|
self.assertEqual(o["shared_entries"], [])
|
|
|
|
def test_production_container_failed_script_copy_cleans_prefetched_inputs_too(self):
|
|
r, o = self.exercise("connected_container", "script_copy")
|
|
self.assertEqual(r.returncode, 1, r.stdout + r.stderr)
|
|
self.assertIsNone(o["copied"])
|
|
self.assertNotIn("EXECUTE", o["calls"])
|
|
self.assertEqual(o["shared_entries"], [])
|
|
|
|
def test_production_local_installer_and_archive_remain_caller_owned(self):
|
|
r, o = self.exercise("local_container")
|
|
self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
|
|
self.assertEqual(o["transport"], [])
|
|
self.assertEqual(o["override"], (b"caller archive", b"caller signature"))
|
|
self.assertEqual(o["copied"], b"#!/bin/bash\n# inert signed fixture\n")
|
|
self.assertEqual(o["caller-script"], b"#!/bin/bash\n# inert signed fixture\n")
|
|
self.assertEqual(o["shared_entries"], [])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|