diff --git a/scripts/installtests/agent_nas_teardown.py b/scripts/installtests/agent_nas_teardown.py index 06092aaf68..e5213826f1 100644 --- a/scripts/installtests/agent_nas_teardown.py +++ b/scripts/installtests/agent_nas_teardown.py @@ -425,7 +425,20 @@ remove_safe_profile_state_dir() { rm -rf -- "$SAFE_PROFILE_STATE_DIR"; } root = Path(directory).resolve() for name in ('state', 'bin', 'tools', 'flash', 'logs'): (root / name).mkdir(mode=0o700) runtime = root / 'bin/pulse-agent'; stored = root / 'state/stored-agent' - stored.write_bytes(Path('/bin/sleep').read_bytes()); stored.chmod(0o700) + # Supply a native Go executable like the actual collector, + # not a renamed platform-owned /bin/sleep with implicit + # executable/process semantics. The generated production + # wrapper still copies it to the exact runtime path, and real + # kernel rows still drive the unchanged ownership/stop checks. + build = subprocess.run( + ['go', 'build', '-trimpath', '-o', str(stored), + './scripts/installtests/nasfixturecollector'], + cwd=ROOT, capture_output=True, text=True, timeout=30, + ) + self.assertEqual(build.returncode, 0, build.stdout + build.stderr) + self.assertTrue(stored.is_file()) + self.assertFalse(stored.is_symlink()) + stored.chmod(0o700) watchdog = root / 'state/start-pulse-agent.sh' (root / 'tools/pkill').write_text('#!/bin/sh\nexit 0\n'); (root / 'tools/pkill').chmod(0o700) (root / 'tools/flash').write_text('#!/bin/sh\nexit 0\n'); (root / 'tools/flash').chmod(0o700) @@ -467,6 +480,7 @@ remove_safe_profile_state_dir() { rm -rf -- "$SAFE_PROFILE_STATE_DIR"; } control = root / 'control.sh' control.write_text('set -euo pipefail\n' + setup + '\n' + functions + '\ncommand() { [[ "$*" != "-v systemctl" ]] || return 1; builtin command "$@"; }\nquiesce_nas_agent_for_removal ' + platform + '\n') output = root / 'watchdog.log' + collector_log = root / 'logs' / ('pulse-agent-watchdog.log' if platform == 'QNAP' else 'watchdog.log') with output.open('wb') as stream: process = subprocess.Popen([str(watchdog)] if platform == 'QNAP' else ['bash', str(watchdog)], env=env, stdout=stream, stderr=stream, start_new_session=True) @@ -474,7 +488,9 @@ remove_safe_profile_state_dir() { rm -rf -- "$SAFE_PROFILE_STATE_DIR"; } deadline = time.monotonic() + 8 while time.monotonic() < deadline: listing = subprocess.run([str(fixture_ps), '-ax', '-o', 'comm=', '-o', 'args='], capture_output=True, text=True, check=True).stdout - if generated_collector_is_running(listing, runtime): break + if (generated_collector_is_running(listing, runtime) + and collector_log.is_file() + and 'private NAS fixture collector ready\n' in collector_log.read_text()): break self.assertIsNone(process.poll(), output.read_text()); time.sleep(.05) else: # Retain only owned fixture rows/logs, never other diff --git a/scripts/installtests/nasfixturecollector/main.go b/scripts/installtests/nasfixturecollector/main.go new file mode 100644 index 0000000000..af6eddbe45 --- /dev/null +++ b/scripts/installtests/nasfixturecollector/main.go @@ -0,0 +1,35 @@ +// nasfixturecollector is an inert, native-built input for the private NAS +// watchdog/removal fixture. It has no agent, service, network or state access. +package main + +import ( + "errors" + "fmt" + "io" + "os" + "time" +) + +const ready = "private NAS fixture collector ready\n" + +func run(args []string, output io.Writer, wait func(time.Duration)) error { + // The generated wrapper passes this one fixed lifetime. Reject anything + // else rather than allowing an accidentally unbounded fixture process. + if len(args) != 1 || args[0] != "30" { + return errors.New("private NAS fixture requires its fixed 30-second lifetime") + } + if n, err := io.WriteString(output, ready); err != nil || n != len(ready) { + return errors.New("private NAS fixture could not record readiness") + } + wait(30 * time.Second) + return nil +} + +func main() { + // Both generated wrappers discard stdout and retain stderr in their own + // watchdog log. This fixed marker discloses no process arguments or paths. + if err := run(os.Args[1:], os.Stderr, time.Sleep); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/scripts/installtests/nasfixturecollector/main_test.go b/scripts/installtests/nasfixturecollector/main_test.go new file mode 100644 index 0000000000..b5e8d0f963 --- /dev/null +++ b/scripts/installtests/nasfixturecollector/main_test.go @@ -0,0 +1,58 @@ +package main + +import ( + "bytes" + "errors" + "io" + "testing" + "time" +) + +func TestCollectorRecordsReadinessBeforeBoundedWait(t *testing.T) { + var output bytes.Buffer + calls := 0 + if err := run([]string{"30"}, &output, func(duration time.Duration) { + calls++ + if duration != 30*time.Second || output.String() != ready { + t.Fatalf("wait %s before readiness %q", duration, output.String()) + } + }); err != nil { + t.Fatal(err) + } + if calls != 1 { + t.Fatalf("wait called %d times", calls) + } +} + +func TestCollectorRejectsChangedOrMissingLifetime(t *testing.T) { + for _, args := range [][]string{nil, {"0"}, {"-1"}, {"300"}, {"30", "extra"}} { + t.Run("invalid", func(t *testing.T) { + var output bytes.Buffer + err := run(args, &output, func(time.Duration) { t.Fatal("invalid input started a collector") }) + if err == nil || output.Len() != 0 { + t.Fatalf("invalid input produced readiness or success: %v, %q", err, output.String()) + } + }) + } +} + +type refusedOutput struct{} + +func (refusedOutput) Write([]byte) (int, error) { return 0, errors.New("synthetic write refusal") } + +type shortOutput struct{} + +func (shortOutput) Write([]byte) (int, error) { return 1, nil } + +func TestCollectorCannotWaitAfterUnrecordedReadiness(t *testing.T) { + for _, output := range []io.Writer{refusedOutput{}, shortOutput{}} { + if err := run([]string{"30"}, output, func(time.Duration) { + t.Fatal("failed readiness started a collector") + }); err == nil { + t.Fatal("failed readiness accepted") + } + } + if err := run([]string{"30"}, io.Discard, func(time.Duration) {}); err != nil { + t.Fatal(err) + } +}