mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-25 21:20:00 +00:00
Use a native-built inert collector in NAS watchdog controls
The private NAS fixture copied /bin/sleep and relied on its renamed process identity. Build a bounded Go collector instead and require its fixed readiness marker through the generated wrapper's retained stderr, as well as exact kernel process ownership. Preserve actual wrappers, teardown/parser, all stop and cleanup assertions, and existing deadlines. This removes a platform-owned executable assumption; it does not diagnose or clear the preceding macOS failures or establish native appliance recovery. Contract-Neutral: Private fixture executable and readiness proof only; production installer, process ownership, safety deadlines and wire contracts are unchanged. Change-source: pulse-maintainer
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user