Run the QNAP agent from the data volume instead of the RAM-backed root

The installer staged the download in /tmp and installed the runtime
binary to /usr/local/bin, both on the small RAM-backed QTS/QuTS hero
root, and the boot wrapper copied 34MiB back onto that root at every
boot. Roots without ~50MiB of headroom could not install at all, and
setting TMPDIR only moved the staging half of the requirement.

QNAP's own QPKG packages execute from the data volume, so do the same:
relocate the install dir to the data volume's state dir before the
preflight and download, default TMPDIR there too, skip the boot-time
self-copy when the stored and runtime binaries are one file, and remove
a pre-relocation runtime copy from /usr/local/bin to give that space
back. Split layouts with an operator-supplied state dir keep the copy
semantics. The rendered wrapper is exercised in both layouts by the
installer tests.

Refs #1617

Contract-Neutral: Refs #1617: QNAP installer layout fix with its deployment-installability contract clause staged in this commit; residual proof policies for unrelated boundaries do not apply to this shell-only change
This commit is contained in:
rcourtman
2026-08-21 06:35:52 +01:00
parent b549e232a5
commit 5c13befcac
3 changed files with 162 additions and 4 deletions
@@ -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
+36 -3
View File
@@ -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
@@ -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")
}