diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 3bd18b73a..c0d059e7d 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -3330,7 +3330,13 @@ the writable data volume, write a flash-backed `autorun.sh` block that waits for that volume before launching the wrapper, recover the same state during uninstall, and keep the persisted boot copy aligned with updater-owned runtime binary replacements instead of assuming `/usr/local/bin` survives reboot on -QTS/QuTS hero. +QTS/QuTS hero. Because that root is a small RAM-backed volume that can lack +the headroom to stage or hold the agent at all, the installer must stage, +install, and run the agent binary from the data volume itself, defaulting the +staging `TMPDIR` there when the operator has not chosen one, reclaiming any +pre-relocation runtime copy left under `/usr/local/bin`, and keeping the +boot-time runtime copy only for split layouts where an operator-supplied +state directory separates the stored and runtime binaries. Before any agent artifact download or replacement, that boundary must also prove adequate space on the effective temporary and install filesystems, deduplicating the requirement when both paths share a filesystem and honoring diff --git a/scripts/install.sh b/scripts/install.sh index 0c82e6453..e57eb8c8d 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1651,8 +1651,12 @@ wait_for_file "${stored_binary}" pkill -x "pulse-agent" 2>/dev/null || true sleep 2 -mkdir -p "$(dirname "$runtime_binary")" 2>/dev/null || true -cp "${stored_binary}" "${runtime_binary}" +# When the runtime binary lives on the data volume it IS the stored binary; +# only a split layout needs the boot-time copy back onto the root. +if [ "${stored_binary}" != "${runtime_binary}" ]; then + mkdir -p "$(dirname "$runtime_binary")" 2>/dev/null || true + cp "${stored_binary}" "${runtime_binary}" +fi chmod +x "${runtime_binary}"${service_env_lines} # Watchdog loop: restart agent if it exits. @@ -3674,6 +3678,25 @@ elif [[ "$(uname -s)" == "FreeBSD" ]] && [[ -d /data ]] && ! is_install_dir_writ log_info "Immutable filesystem detected (read-only /usr/local/bin). Using $TRUENAS_STATE_DIR for installation." fi +# QNAP QTS/QuTS hero: the root filesystem is a small RAM-backed volume that is +# rebuilt on every boot, so staging to /tmp and installing to /usr/local/bin +# can both fail on space and never persist anyway (issue #1617). QNAP's own +# QPKG packages execute from the data volume, so stage, install, and run the +# agent from there. +if [[ "$(uname -s)" == "Linux" ]] && { [[ -f /sbin/getcfg ]] || [[ -f /etc/config/qpkg.conf ]]; }; then + QNAP_EARLY_VOL=$(detect_qnap_data_volume || true) + if [[ -n "$QNAP_EARLY_VOL" ]]; then + INSTALL_DIR="${QNAP_EARLY_VOL}/.pulse-agent" + if [[ -z "${TMPDIR:-}" ]]; then + QNAP_STAGING_TMPDIR="${QNAP_EARLY_VOL}/.pulse-agent/tmp" + if mkdir -p "$QNAP_STAGING_TMPDIR" 2>/dev/null; then + export TMPDIR="$QNAP_STAGING_TMPDIR" + fi + fi + log_info "QNAP detected (RAM-backed root). Staging and installing under ${INSTALL_DIR}." + fi +fi + # --- Preflight-Only Mode --- if [[ "$PREFLIGHT_ONLY" == "true" ]]; then json_event "preflight" "checking" "Running preflight checks" @@ -4200,10 +4223,20 @@ if [[ -f /sbin/getcfg ]] || [[ -f /etc/config/qpkg.conf ]]; then mkdir -p "$STATE_DIR" # Copy binary to persistent storage and keep the runtime copy executable. - cp "${RUNTIME_BINARY}" "$QNAP_STORED_BINARY" + # With the data-volume install dir these are the same file; the copy only + # applies when a custom STATE_DIR separates them. + if [[ "$RUNTIME_BINARY" != "$QNAP_STORED_BINARY" ]]; then + cp "${RUNTIME_BINARY}" "$QNAP_STORED_BINARY" + fi chmod +x "$QNAP_STORED_BINARY" chmod +x "$RUNTIME_BINARY" + # A pre-relocation install left its runtime copy on the RAM-backed root; + # reclaim that space now that the agent runs from the data volume. + if [[ "$RUNTIME_BINARY" != "/usr/local/bin/${BINARY_NAME}" ]]; then + rm -f "/usr/local/bin/${BINARY_NAME}" + fi + log_info "Installed binary to ${QNAP_STORED_BINARY} (persistent) and ${RUNTIME_BINARY} (runtime)..." # Log to the data volume with the agent's rotating writer; the RAM-backed diff --git a/scripts/installtests/install_sh_qnap_data_volume_test.go b/scripts/installtests/install_sh_qnap_data_volume_test.go new file mode 100644 index 000000000..8005c5c4a --- /dev/null +++ b/scripts/installtests/install_sh_qnap_data_volume_test.go @@ -0,0 +1,119 @@ +package installtests + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "testing" + "time" +) + +// The QNAP root filesystem is a small RAM-backed volume rebuilt on every +// boot, so the installer must stage, install, and run the agent from the +// data volume instead. Refs #1617. +func TestInstallSHRelocatesQNAPInstallToDataVolume(t *testing.T) { + content, err := os.ReadFile(repoFile("scripts", "install.sh")) + if err != nil { + t.Fatalf("read install.sh: %v", err) + } + + script := string(content) + required := []string{ + `QNAP_EARLY_VOL=$(detect_qnap_data_volume || true)`, + `INSTALL_DIR="${QNAP_EARLY_VOL}/.pulse-agent"`, + `export TMPDIR="$QNAP_STAGING_TMPDIR"`, + `if [[ "$RUNTIME_BINARY" != "$QNAP_STORED_BINARY" ]]; then`, + `rm -f "/usr/local/bin/${BINARY_NAME}"`, + } + for _, needle := range required { + if !strings.Contains(script, needle) { + t.Fatalf("install.sh missing QNAP data-volume install handling: %s", needle) + } + } +} + +// With the data-volume layout the stored and runtime binaries are one file; +// the rendered watchdog must start the agent without a boot-time self-copy. +func TestRenderedQNAPWatchdogRunsUnifiedStoredRuntimeBinary(t *testing.T) { + content, err := os.ReadFile(repoFile("scripts", "install.sh")) + if err != nil { + t.Fatalf("read install.sh: %v", err) + } + + script := string(content) + start := strings.Index(script, "write_qnap_wrapper_script() {") + if start < 0 { + t.Fatal("install.sh missing QNAP wrapper renderer") + } + endOffset := strings.Index(script[start:], "\nappend_qnap_autorun_block() {") + if endOffset < 0 { + t.Fatal("could not isolate QNAP wrapper renderer") + } + renderer := script[start : start+endOffset] + + tempDir := t.TempDir() + stateDir := filepath.Join(tempDir, "state") + logDir := filepath.Join(stateDir, "logs") + wrapperPath := filepath.Join(stateDir, "start-pulse-agent.sh") + binaryPath := filepath.Join(stateDir, "pulse-agent") + startsPath := filepath.Join(tempDir, "agent-starts") + mockBinDir := filepath.Join(tempDir, "bin") + + for _, dir := range []string{stateDir, mockBinDir} { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("create test directory: %v", err) + } + } + agentScript := "#!/bin/sh\n" + + "echo \"$$\" >> \"" + startsPath + "\"\n" + + "trap 'exit 0' INT TERM HUP\n" + + "while :; do sleep 1; done\n" + if err := os.WriteFile(binaryPath, []byte(agentScript), 0o755); err != nil { + t.Fatalf("write fake agent: %v", err) + } + if err := os.WriteFile(filepath.Join(mockBinDir, "pkill"), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatalf("write fake pkill: %v", err) + } + + harness := renderer + ` +AGENT_NAME=pulse-agent +SHELL_EXPORT_LINES="" +EXEC_ARGS="" +write_qnap_wrapper_script "$1" "$2" "$3" "$4" "$5" +` + render := exec.Command("bash", "-c", harness, "_", wrapperPath, binaryPath, binaryPath, logDir, stateDir) + if output, err := render.CombinedOutput(); err != nil { + t.Fatalf("render QNAP wrapper: %v\n%s", err, output) + } + + rendered, err := os.ReadFile(wrapperPath) + if err != nil { + t.Fatalf("read rendered wrapper: %v", err) + } + if !strings.Contains(string(rendered), `if [ "`+binaryPath+`" != "`+binaryPath+`" ]; then`) { + t.Fatal("rendered wrapper lost the unified-layout copy guard") + } + + watchdog := exec.Command("sh", wrapperPath) + watchdog.Env = []string{"PATH=" + mockBinDir + ":/usr/bin:/bin"} + if err := watchdog.Start(); err != nil { + t.Fatalf("start watchdog: %v", err) + } + t.Cleanup(func() { + if watchdog.Process != nil { + _ = watchdog.Process.Signal(syscall.SIGTERM) + _, _ = watchdog.Process.Wait() + } + }) + + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + if _, err := os.Stat(startsPath); err == nil { + return + } + time.Sleep(25 * time.Millisecond) + } + t.Fatal("timed out waiting for the unified-layout watchdog to start the agent") +}