diff --git a/internal/api/agent_install_command_shared_test.go b/internal/api/agent_install_command_shared_test.go index 3c495430a5..b689ff4111 100644 --- a/internal/api/agent_install_command_shared_test.go +++ b/internal/api/agent_install_command_shared_test.go @@ -14,6 +14,17 @@ import ( "github.com/stretchr/testify/require" ) +func requirePrivateBootstrapPrivilegeDispatch(t *testing.T, command string) { + t.Helper() + // The shared producer selects its root/sudo argv inside an isolated + // subshell. Duplicating the credential reader into literal root and sudo + // branches would exceed the common pasted-line budget. + require.Contains(t, command, `elif command -v sudo >/dev/null 2>&1; then sudo -v;`) + require.Contains(t, command, `if [ "$(id -u)" -eq 0 ]; then set --; else set -- sudo; fi;`) + require.Contains(t, command, `"$@" bash -c `) + require.Equal(t, 1, strings.Count(command, "bash -c "), "the privileged body must be emitted once") +} + func TestNormalizeProxmoxInstallType(t *testing.T) { installType, err := normalizeProxmoxInstallType(" PBS ") require.NoError(t, err) @@ -92,7 +103,7 @@ func TestBuildProxmoxAgentInstallCommand(t *testing.T) { require.NotContains(t, command, "token-123") require.Contains(t, command, `--token-file "$token_file"`) require.Contains(t, command, `token_dir=$(mktemp -d /tmp/pulse-agent-bootstrap.XXXXXX)`) - require.Contains(t, command, `sudo bash -c`) + requirePrivateBootstrapPrivilegeDispatch(t, command) require.Contains(t, command, `rmdir -- "$token_dir"`) require.Contains(t, command, "pbs") require.NotContains(t, command, "--enable-commands") @@ -213,7 +224,7 @@ func TestBuildContainerRuntimeAgentInstallCommand_UsesLifecycleTransport(t *test require.Contains(t, command, "--enable-host") require.NotContains(t, command, "--enable-host=false") require.Contains(t, command, "--interval 30s") - require.Contains(t, command, `sudo bash -c`) + requirePrivateBootstrapPrivilegeDispatch(t, command) require.Contains(t, command, "--preflight-only --output json --non-interactive") require.Contains(t, command, `read -r -s -p`) require.NotContains(t, command, "| bash") @@ -262,7 +273,7 @@ func TestBuildSetupScriptCommand_OmitsTokenWhenNotProvided(t *testing.T) { require.Contains(t, command, "curl -fsSL "+posixShellQuote("https://pulse.example.com/api/setup-script?type=pbs")+` -o "$install_script"`) require.Contains(t, command, `if [ "$(id -u)" -eq 0 ]; then`) - require.Contains(t, command, `sudo bash -c`) + requirePrivateBootstrapPrivilegeDispatch(t, command) require.NotContains(t, command, "PULSE_SETUP_TOKEN=") } diff --git a/internal/api/configapi/install_command_test.go b/internal/api/configapi/install_command_test.go index d46eb77acc..e619f7983c 100644 --- a/internal/api/configapi/install_command_test.go +++ b/internal/api/configapi/install_command_test.go @@ -40,6 +40,16 @@ exit_sent = False command_ready = not history command_tail = b"" send_tail_at = 0 +signal_observation = None +def reader_signal_state(reader): + # Linux-only diagnostic fields, never cmdline, environment or input. Other + # platforms retain the sent/exit/terminal observations without these masks. + try: + with open("/proc/%d/status" % reader) as f: + return {k: v.strip() for k, v in (line.split(":", 1) for line in f) + if k in ("Name", "State", "SigBlk", "SigIgn", "SigCgt", "SigPnd", "ShdPnd")} + except OSError: + return {"status": "unavailable"} deadline = time.monotonic() + 15 while time.monotonic() < deadline: ready, _, _ = select.select([master], [], [], .1) @@ -87,19 +97,30 @@ while time.monotonic() < deadline: # signalling the entire session kills that observation owner. with open(os.path.join(env["FAKE_ROOT"], "reader-pid")) as f: reader = int(f.read()) + signal_observation = {"signal": p["signal_on_prompt"], "sent": False, + "before": reader_signal_state(reader)} os.kill(reader, getattr(signal, "SIG" + p["signal_on_prompt"])) + signal_observation["sent"] = True else: os.write(master, (p["input"] + "\n").encode()) sent = True if child.poll() is not None and not ready: break else: + if signal_observation: + signal_observation["at_deadline"] = reader_signal_state(reader) os.killpg(child.pid, signal.SIGKILL) - output += b"\nPTY fixture deadline expired\n" + output += ("\nPTY fixture deadline expired (prompt accepted=%s, signal sent=%s)\n" % + (sent, bool(signal_observation and signal_observation["sent"]))).encode() code = child.wait() final_tty = termios.tcgetattr(tty_watch) if initial_tty != final_tty: output += b"\nBootstrap did not restore complete terminal settings\n" code = 1 +if p.get("signal_on_prompt"): + observation = signal_observation or {"signal": p["signal_on_prompt"], "sent": False} + observation.update(exit=code, complete_tty_restored=initial_tty == final_tty) + with open(os.path.join(env["FAKE_ROOT"], "signal-observation"), "w") as f: + json.dump(observation, f, sort_keys=True) os.close(tty_watch) os.close(master) sys.stdout.buffer.write(output) diff --git a/internal/api/configapi/portable_bootstrap_test.go b/internal/api/configapi/portable_bootstrap_test.go index ec76a2a6b1..beb6b52a9d 100644 --- a/internal/api/configapi/portable_bootstrap_test.go +++ b/internal/api/configapi/portable_bootstrap_test.go @@ -205,6 +205,21 @@ func TestPrivateBootstrapRealBusyBoxInput(t *testing.T) { root, env := portableBootstrapFixture(t, "busybox") command := caller.build("issued-separately") out, err := runPortableBootstrap(t, command, tc.input, env, "", tc.signalName) + if tc.signalName != "" { + body, readErr := os.ReadFile(filepath.Join(root, "signal-observation")) + if readErr != nil { + t.Fatal("signal execution observation missing") + } + var observation struct { + Sent bool `json:"sent"` + } + if json.Unmarshal(body, &observation) != nil || !observation.Sent { + t.Fatal("requested reader signal was not actually sent") + } + // This fixed-field record contains no command, input or environment. + // Keep delivery separate from a successful cleanup assertion. + t.Logf("reader signal observation: %s", body) + } if (err != nil) != tc.failure { t.Fatalf("BusyBox bootstrap result=%v, expected failure=%t\n%s", err, tc.failure, out) } diff --git a/internal/api/router_low_coverage_additional_test.go b/internal/api/router_low_coverage_additional_test.go index 3e6a8ce694..579cfa8bbf 100644 --- a/internal/api/router_low_coverage_additional_test.go +++ b/internal/api/router_low_coverage_additional_test.go @@ -179,7 +179,8 @@ func TestHandleDiagnosticsDockerPrepareToken_Success(t *testing.T) { if !strings.Contains(payload["installCommand"].(string), "--enable-docker") { t.Fatalf("expected install command to enable docker metrics: %q", payload["installCommand"]) } - if !strings.Contains(payload["installCommand"].(string), `sudo bash -c`) || !strings.Contains(payload["installCommand"].(string), `--token-file "$token_file"`) { + requirePrivateBootstrapPrivilegeDispatch(t, payload["installCommand"].(string)) + if !strings.Contains(payload["installCommand"].(string), `--token-file "$token_file"`) { t.Fatal("expected install command to preserve private token input through root or sudo") } for _, field := range []string{"installCommand", "systemdServiceSnippet"} {