Skip to content

Commit a3128e6

Browse files
committed
feat: support running Sessions as a jobRunAsUser on macOS
openjd-sessions could not run a Session's actions as a separate user (jobRunAsUser) on macOS. Three problems in the POSIX cross-user path: - setsid(1) does not exist on macOS, so the cross-user command 'sudo -u <user> -i setsid -w <cmd>' failed at spawn (exit 127), breaking all jobRunAsUser execution on macOS. - find_child_process_id_pgrep treated pgrep's exit code 1 (no match yet) as a fatal error, which broke out of the caller's retry loop. Since Linux uses procfs and only other POSIX platforms use pgrep, signal-target discovery never actually retried off Linux. - There was no is_macos() helper (the MACOS constant was unused). Changes: - _os_checker.py: add is_macos(). - _subprocess.py: on darwin, launch the workload via a pure-Python setsid shim ('sudo -u <user> -i /usr/bin/python3 -I -c <shim> <cmd>') that makes the workload a new session/process-group leader before exec, reproducing the new-session behavior 'setsid -w' provides on Linux. Runs under /usr/bin/python3 (so the job user needs no venv access) with -I (isolated mode, so the working directory is not on sys.path). - _linux/_sudo.py: treat pgrep exit code 1 as "no match yet -> return None" so the retry loop polls as intended; exit >1 is still fatal. Fixes discovery and cancellation on all non-Linux POSIX hosts. - Tests for is_macos(), the pgrep exit-code handling, and the macOS command construction (plus a POSIX check that the shim creates a new process group). - README: document the macOS Command Line Tools prerequisite for impersonation. Linux and Windows behavior is unchanged. Validated end-to-end on macOS 26.5 (arm64) via the AWS Deadline Cloud worker agent against a live customer-managed fleet: running an action as a jobRunAsUser and cancellation reaping the workload's process group with no orphans. Signed-off-by: Andy Choquette <apcho@amazon.com>
1 parent f0bfb98 commit a3128e6

7 files changed

Lines changed: 225 additions & 3 deletions

File tree

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,12 @@ with passwordless `sudo` by, for example, adding a rule like follows to your
234234
host ALL=(actions) NOPASSWD: ALL
235235
```
236236

237+
On MacOS, impersonated Sessions additionally require the Xcode Command Line Tools to be
238+
installed on the host. macOS lacks the `setsid(1)` utility, so the impersonated command is
239+
launched via the operating system's `/usr/bin/python3`, which resolves to a working
240+
interpreter only when the Command Line Tools (or Xcode) are present. Install them with
241+
`xcode-select --install`.
242+
237243
#### Impersonating a User: Windows Systems
238244

239245
To run an impersonated Session on Windows Systems modify the "Running a Session" example

src/openjd/sessions/_linux/_sudo.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,12 @@ def find_child_process_id_pgrep(
143143
stdin=DEVNULL,
144144
text=True,
145145
)
146+
# pgrep exit codes: 0 = one or more processes matched; 1 = no processes matched;
147+
# >1 = an actual error (syntax/operational). Exit 1 is NOT an error here -- it just
148+
# means sudo has not spawned its child yet, so we return None to let the caller's
149+
# retry loop poll again.
150+
if pgrep_result.returncode == 1:
151+
return None
146152
if pgrep_result.returncode != 0:
147153
raise FindSignalTargetError("Unable to query child processes of sudo process")
148154
results = pgrep_result.stdout.splitlines()

src/openjd/sessions/_os_checker.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ def is_windows() -> bool:
2121
return os.name == WINDOWS
2222

2323

24+
def is_macos() -> bool:
25+
return sys.platform == MACOS
26+
27+
2428
def check_os() -> None:
2529
if not (is_posix() or is_windows()):
2630
raise NotImplementedError(

src/openjd/sessions/_subprocess.py

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from ._linux._capabilities import try_use_cap_kill
1717
from ._linux._sudo import find_sudo_child_process_group_id
1818
from ._logging import LoggerAdapter, LogContent, LogExtraInfo
19-
from ._os_checker import is_linux, is_posix, is_windows
19+
from ._os_checker import is_linux, is_macos, is_posix, is_windows
2020
from ._session_user import PosixSessionUser, WindowsSessionUser, SessionUser
2121
from ._action_filter import redact_openjd_redacted_env_requests
2222

@@ -28,6 +28,37 @@
2828

2929
__all__ = ("LoggingSubprocess",)
3030

31+
# macOS has no `setsid(1)` binary (it is a Linux/util-linux tool), yet the new-session
32+
# behavior it provides is still required: `sudo -u <user> -i <cmd>` places the workload in
33+
# sudo's own (root-owned) process group, which the jobRunAsUser cannot signal and which
34+
# openjd must not signal (it would hit the root sudo process). We reproduce `setsid` with a
35+
# tiny pure-Python shim, run as the workload, that makes the workload a new session/process-
36+
# group leader and then exec's the real command.
37+
#
38+
# Details:
39+
# * `os.getpgrp() == os.getpid() or os.setsid()` calls setsid() only when the process is
40+
# NOT already a group leader; os.setsid() raises EPERM if the caller already leads a
41+
# group, so the short-circuit avoids that. Either way the workload ends up in a process
42+
# group distinct from sudo's, which find_sudo_child_process_group_id() then discovers.
43+
# * Single line (no newlines) so it passes cleanly through `sudo -i` argv without any
44+
# shell-quoting fragility.
45+
# * /usr/bin/python3 (the OS-provided interpreter) is used rather than sys.executable so
46+
# the jobRunAsUser can execute it without traverse/read permission on the agent's venv.
47+
# On macOS /usr/bin/python3 is the Command Line Tools shim; the host must have the
48+
# Command Line Tools (or Xcode) installed for it to resolve to a working interpreter.
49+
# * `-I` (isolated mode) drops the current working directory from sys.path and ignores
50+
# PYTHON* environment variables, so a file such as os.py in the session working directory
51+
# cannot be imported ahead of the standard library before os.execvp() runs.
52+
#
53+
# Signal-target discovery (find_sudo_child_process_group_id) locates the workload by walking
54+
# sudo's single child and comparing process groups. This relies on `sudo -i` exec'ing the
55+
# command into a single child rather than leaving extra long-lived processes in between; the
56+
# same assumption already holds for the Linux `setsid -w` path.
57+
_MACOS_SETSID_SHIM = (
58+
"import os,sys;os.getpgrp()==os.getpid() or os.setsid();os.execvp(sys.argv[1],sys.argv[1:])"
59+
)
60+
_MACOS_SETSID_INTERPRETER_ARGS = ["/usr/bin/python3", "-I"]
61+
3162
# ========================================================================
3263
# ========================================================================
3364
# DEVELOPER NOTE:
@@ -257,7 +288,23 @@ def _start_subprocess(self) -> Optional[Popen]:
257288
# same process group as the `sudo` command. If that happens, then
258289
# we're stuck: 1/ Our user cannot kill processes by the self._user; and
259290
# 2/ The self._user cannot kill the root-owned sudo process group.
260-
command.extend(["sudo", "-u", user.user, "-i", "setsid", "-w"])
291+
if is_macos():
292+
# macOS has no setsid(1); use a pure-Python setsid shim (see
293+
# _MACOS_SETSID_SHIM) run as the workload to get the same
294+
# new-session behavior that `setsid -w` provides on Linux.
295+
command.extend(
296+
[
297+
"sudo",
298+
"-u",
299+
user.user,
300+
"-i",
301+
*_MACOS_SETSID_INTERPRETER_ARGS,
302+
"-c",
303+
_MACOS_SETSID_SHIM,
304+
]
305+
)
306+
else:
307+
command.extend(["sudo", "-u", user.user, "-i", "setsid", "-w"])
261308
elif is_windows():
262309
user = cast(WindowsSessionUser, self._user) # type: ignore
263310

test/openjd/sessions_v0/test_os_checker.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import unittest
44
from enum import Enum
55
from unittest.mock import patch
6-
from openjd.sessions._os_checker import is_posix, is_windows, check_os
6+
from openjd.sessions._os_checker import is_macos, is_posix, is_windows, check_os
77

88

99
class OSName(str, Enum):
@@ -32,6 +32,16 @@ def test_is_not_windows(self, mock_os):
3232
mock_os.name = OSName.POSIX
3333
self.assertFalse(is_windows())
3434

35+
@patch("openjd.sessions._os_checker.sys")
36+
def test_is_macos(self, mock_sys):
37+
mock_sys.platform = "darwin"
38+
self.assertTrue(is_macos())
39+
40+
@patch("openjd.sessions._os_checker.sys")
41+
def test_is_not_macos(self, mock_sys):
42+
mock_sys.platform = "linux"
43+
self.assertFalse(is_macos())
44+
3545
@patch("openjd.sessions._os_checker.os")
3646
def test_check_os_posix(self, mock_os):
3747
mock_os.name = OSName.POSIX

test/openjd/sessions_v0/test_subprocess.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1069,3 +1069,81 @@ def end_proc():
10691069
if num_children_running == 0:
10701070
break
10711071
assert num_children_running == 0
1072+
1073+
1074+
@pytest.mark.usefixtures("message_queue", "queue_handler")
1075+
class TestLoggingSubprocessMacOSSetsid:
1076+
"""Tests for the macOS-specific cross-user command construction.
1077+
1078+
macOS has no setsid(1), so on darwin the workload is launched under a small
1079+
pure-Python shim (run via ``/usr/bin/python3 -I``) that becomes a new
1080+
session/process-group leader before exec'ing the real command.
1081+
"""
1082+
1083+
@pytest.mark.skipif(
1084+
is_windows(), reason="Constructs a PosixSessionUser, which is rejected on Windows hosts"
1085+
)
1086+
def test_builds_setsid_shim_command_on_macos(self, queue_handler: QueueHandler) -> None:
1087+
# GIVEN
1088+
from openjd.sessions import _subprocess as subprocess_mod
1089+
1090+
logger = build_logger(queue_handler)
1091+
target_user = MagicMock(spec=PosixSessionUser)
1092+
target_user.user = "job-user"
1093+
target_user.is_process_user.return_value = False
1094+
subproc = LoggingSubprocess(
1095+
logger=logger,
1096+
args=["/path/to/workload.sh"],
1097+
user=target_user,
1098+
)
1099+
1100+
# WHEN
1101+
with (
1102+
patch.object(subprocess_mod, "is_macos", return_value=True),
1103+
patch.object(subprocess_mod, "is_posix", return_value=True),
1104+
patch.object(subprocess_mod, "is_windows", return_value=False),
1105+
patch.object(subprocess_mod, "Popen") as mock_popen,
1106+
):
1107+
subproc._start_subprocess()
1108+
1109+
# THEN
1110+
built_command = mock_popen.call_args.kwargs["args"]
1111+
assert built_command == [
1112+
"sudo",
1113+
"-u",
1114+
"job-user",
1115+
"-i",
1116+
"/usr/bin/python3",
1117+
"-I",
1118+
"-c",
1119+
subprocess_mod._MACOS_SETSID_SHIM,
1120+
"/path/to/workload.sh",
1121+
]
1122+
1123+
@pytest.mark.skipif(not is_posix(), reason="posix-specific test")
1124+
def test_setsid_shim_creates_new_process_group(self) -> None:
1125+
# GIVEN the shim string that macOS uses in place of setsid(1).
1126+
from subprocess import PIPE, run
1127+
1128+
from openjd.sessions import _subprocess as subprocess_mod
1129+
1130+
# WHEN we run it (as the current user; no sudo) to report the workload's
1131+
# process-group id alongside the launching python's own pid.
1132+
result = run(
1133+
[
1134+
sys.executable,
1135+
"-I",
1136+
"-c",
1137+
subprocess_mod._MACOS_SETSID_SHIM,
1138+
"/bin/sh",
1139+
"-c",
1140+
"echo $$ $(ps -o pgid= -p $$)",
1141+
],
1142+
stdout=PIPE,
1143+
text=True,
1144+
check=True,
1145+
)
1146+
1147+
# THEN the workload is the leader of its own process group (pgid == its pid).
1148+
workload_pid, workload_pgid = (int(x) for x in result.stdout.split())
1149+
assert workload_pid == workload_pgid
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
3+
from subprocess import CompletedProcess
4+
from unittest.mock import MagicMock, patch
5+
6+
import pytest
7+
8+
from openjd.sessions._linux._sudo import (
9+
FindSignalTargetError,
10+
find_child_process_id_pgrep,
11+
)
12+
13+
14+
def _pgrep_result(returncode: int, stdout: str = "") -> CompletedProcess:
15+
return CompletedProcess(args=["pgrep"], returncode=returncode, stdout=stdout)
16+
17+
18+
class TestFindChildProcessIdPgrep:
19+
"""Tests for the pgrep-based signal-target discovery used on non-Linux POSIX hosts."""
20+
21+
@patch("openjd.sessions._linux._sudo.run")
22+
def test_returns_child_pid_on_match(self, mock_run: MagicMock) -> None:
23+
# GIVEN pgrep matches a single child process
24+
mock_run.return_value = _pgrep_result(returncode=0, stdout="4321\n")
25+
26+
# WHEN
27+
result = find_child_process_id_pgrep(sudo_pid=1234)
28+
29+
# THEN
30+
assert result == 4321
31+
32+
@patch("openjd.sessions._linux._sudo.run")
33+
def test_returns_none_when_no_match_yet(self, mock_run: MagicMock) -> None:
34+
# GIVEN pgrep finds no matching processes (exit code 1) -- e.g. sudo has not
35+
# spawned its child yet. This must return None (so the caller retries), NOT raise.
36+
mock_run.return_value = _pgrep_result(returncode=1, stdout="")
37+
38+
# WHEN
39+
result = find_child_process_id_pgrep(sudo_pid=1234)
40+
41+
# THEN
42+
assert result is None
43+
44+
@patch("openjd.sessions._linux._sudo.run")
45+
def test_raises_on_pgrep_error(self, mock_run: MagicMock) -> None:
46+
# GIVEN pgrep reports an actual error (exit code > 1)
47+
mock_run.return_value = _pgrep_result(returncode=2, stdout="")
48+
49+
# WHEN / THEN
50+
with pytest.raises(FindSignalTargetError):
51+
find_child_process_id_pgrep(sudo_pid=1234)
52+
53+
@patch("openjd.sessions._linux._sudo.run")
54+
def test_raises_on_multiple_children(self, mock_run: MagicMock) -> None:
55+
# GIVEN pgrep matches more than one child, violating the single-child assumption
56+
mock_run.return_value = _pgrep_result(returncode=0, stdout="4321\n4322\n")
57+
58+
# WHEN / THEN
59+
with pytest.raises(FindSignalTargetError):
60+
find_child_process_id_pgrep(sudo_pid=1234)
61+
62+
@patch("openjd.sessions._linux._sudo.run")
63+
def test_returns_none_on_empty_stdout_with_success(self, mock_run: MagicMock) -> None:
64+
# GIVEN pgrep exits 0 but with no pids in stdout (defensive: treat as no match)
65+
mock_run.return_value = _pgrep_result(returncode=0, stdout="")
66+
67+
# WHEN
68+
result = find_child_process_id_pgrep(sudo_pid=1234)
69+
70+
# THEN
71+
assert result is None

0 commit comments

Comments
 (0)