Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ classifiers = [
"Topic :: Software Development :: Libraries"
]
dependencies = [
"openjd-model >= 0.9,< 0.10",
"openjd-model >= 0.10,< 0.11",
"pywin32 >= 307; platform_system == 'Windows'",
"psutil >= 5.9,< 7.3; platform_system == 'Windows'",
]
Expand Down Expand Up @@ -130,6 +130,7 @@ known-first-party = [
# https://mypy.readthedocs.io/en/stable/common_issues.html#python-version-and-system-platform-checks
# This causes imports to come after regular Python statements causing flake8 rule E402 to be flagged
"src/openjd/sessions/_win32/*.py" = ["E402"]
"src/openjd/sessions/_v1/_win32/*.py" = ["E402"]


[tool.black]
Expand Down Expand Up @@ -172,7 +173,7 @@ source = [

[tool.coverage.report]
show_missing = true
fail_under = 79
fail_under = 50

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The coverage gate is being lowered from 79% to 50%. This is a substantial regression in the enforced quality bar for the package as a whole. Given that most of the new _v1 code (e.g. the Win32 ctypes layer, _capabilities.py) is thin OS-binding glue that is hard to unit test, this may be intentional — but it also means the new pure-Python logic (_session.py polling/callback state machine, _sudo.py child-discovery) can land largely uncovered. Worth confirming this drop is a deliberate, temporary concession rather than masking untested new logic.


# https://github.com/wemake-services/coverage-conditional-plugin
[tool.coverage.coverage_conditional_plugin.omit]
Expand Down
55 changes: 55 additions & 0 deletions src/openjd/sessions/_v1/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.

from ._logging import LOG, LogContent
from ._path_mapping import PathFormat, PathMappingRule
from ._session import ActionStatus, Session, SessionCallbackType, SessionState
from ._session_user import (
PosixSessionUser,
SessionUser,
WindowsSessionUser,
BadCredentialsException,
)
from ._types import (
ActionState,
EnvironmentIdentifier,
EnvironmentModel,
EnvironmentScriptModel,
StepScriptModel,
)
from .._version import version

# Rust-backed types
from openjd._openjd_rs import (
ScriptRunnerState,
ActionResult,
SessionError as SessionRuntimeError,
)

# Note: the `__module__` / `__name__` / `__qualname__` of the Rust-backed
# exceptions (SessionError, BadCredentialsException) are set by the
# `_openjd_rs` module init in Rust to their canonical user-facing values
# (e.g. `openjd.sessions._v1.SessionError`). No Python-side fix-up needed.

__all__ = (
"ActionState",
"ActionStatus",
"ActionResult",
"EnvironmentIdentifier",
"EnvironmentModel",
"EnvironmentScriptModel",
"LOG",
"LogContent",
"PathFormat",
"PathMappingRule",
"PosixSessionUser",
"ScriptRunnerState",
"Session",
"SessionCallbackType",
"SessionRuntimeError",
"SessionState",
"SessionUser",
"StepScriptModel",
"WindowsSessionUser",
"BadCredentialsException",
"version",
)
258 changes: 258 additions & 0 deletions src/openjd/sessions/_v1/_linux/_capabilities.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.

"""This module contains code for interacting with Linux capabilities. The module uses the ctypes

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should rebase, and get this PR merged soon since the OpenJD side is merged.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pending the release of OpenJD-model once it is released.

module from the Python standard library to wrap the libcap library.

See https://man7.org/linux/man-pages/man7/capabilities.7.html for details on this Linux kernel
feature.
"""

import ctypes
import os
import sys
from contextlib import contextmanager
from ctypes.util import find_library
from enum import Enum
from functools import cache
from typing import Any, Generator, Optional, Tuple, TYPE_CHECKING


from .._logging import LOG


# Capability sets
CAP_EFFECTIVE = 0
CAP_PERMITTED = 1
CAP_INHERITABLE = 2

# Capability bit numbers
CAP_KILL = 5

# Values for cap_flag_value_t arguments
CAP_CLEAR = 0
CAP_SET = 1

cap_flag_t = ctypes.c_int
cap_flag_value_t = ctypes.c_int
cap_value_t = ctypes.c_int


class CapabilitySetType(Enum):
INHERITABLE = CAP_INHERITABLE
PERMITTED = CAP_PERMITTED
EFFECTIVE = CAP_EFFECTIVE


class UserCapHeader(ctypes.Structure):
_fields_ = [
("version", ctypes.c_uint32),
("pid", ctypes.c_int),
]


class UserCapData(ctypes.Structure):
_fields_ = [
("effective", ctypes.c_uint32),
("permitted", ctypes.c_uint32),
("inheritable", ctypes.c_uint32),
]


class Cap(ctypes.Structure):
_fields_ = [
("head", UserCapHeader),
("data", UserCapData),
]


if TYPE_CHECKING:
cap_t = ctypes._Pointer[Cap]
cap_flag_value_ptr = ctypes._Pointer[cap_flag_value_t]
cap_value_ptr = ctypes._Pointer[cap_value_t]
ssize_ptr_t = ctypes._Pointer[ctypes.c_ssize_t]
else:
cap_t = ctypes.POINTER(Cap)
cap_flag_value_ptr = ctypes.POINTER(cap_flag_value_t)
cap_value_ptr = ctypes.POINTER(cap_value_t)
ssize_ptr_t = ctypes.POINTER(ctypes.c_ssize_t)


def _cap_set_err_check(
result: ctypes.c_int,
func: Any,
args: Tuple[Any, ...],
) -> ctypes.c_int:
if result != 0:
errno = ctypes.get_errno()
raise OSError(errno, os.strerror(errno))
return result


def _cap_get_proc_err_check(
result: cap_t,
func: Any,
args: Tuple[cap_t, cap_flag_t, ctypes.c_int, cap_value_ptr, cap_flag_value_t],
) -> cap_t:
if not result:
errno = ctypes.get_errno()
raise OSError(errno, os.strerror(errno))
return result


def _cap_get_flag_errcheck(
result: ctypes.c_int, func: Any, args: Tuple[cap_t, cap_value_t, cap_flag_t, cap_flag_value_ptr]
) -> ctypes.c_int:
if result != 0:
errno = ctypes.get_errno()
raise OSError(errno, os.strerror(errno))
return result


@cache
def _get_libcap() -> Optional[ctypes.CDLL]:
if not sys.platform.startswith("linux"):
raise OSError(f"libcap is only available on Linux, but found platform: {sys.platform}")

libcap_path = find_library("cap")
if libcap_path is None:
LOG.info(
"Unable to locate libcap. Session action cancelation signals will be sent using sudo"
)
return None

libcap = ctypes.CDLL(libcap_path, use_errno=True)

# https://man7.org/linux/man-pages/man3/cap_set_proc.3.html
libcap.cap_set_proc.restype = ctypes.c_int
libcap.cap_set_proc.argtypes = [
cap_t,
]
libcap.cap_set_proc.errcheck = _cap_set_err_check # type: ignore

# https://man7.org/linux/man-pages/man3/cap_get_proc.3.html
libcap.cap_get_proc.restype = cap_t
libcap.cap_get_proc.argtypes = []
libcap.cap_get_proc.errcheck = _cap_get_proc_err_check # type: ignore

# https://man7.org/linux/man-pages/man3/cap_set_flag.3.html
libcap.cap_set_flag.restype = ctypes.c_int
libcap.cap_set_flag.argtypes = [
cap_t,
cap_flag_t,
ctypes.c_int,
cap_value_ptr,
cap_flag_value_t,
]

# https://man7.org/linux/man-pages/man3/cap_get_flag.3.html
libcap.cap_get_flag.restype = ctypes.c_int
libcap.cap_get_flag.argtypes = (
cap_t,
cap_value_t,
cap_flag_t,
cap_flag_value_ptr,
)
libcap.cap_get_flag.errcheck = _cap_get_flag_errcheck # type: ignore

return libcap


def _has_capability(
*,
libcap: ctypes.CDLL,
caps: cap_t,
capability: int,
capability_set_type: CapabilitySetType,
) -> bool:
flag_value = cap_flag_value_t()
libcap.cap_get_flag(caps, capability, capability_set_type.value, ctypes.byref(flag_value))
return flag_value.value == CAP_SET


@contextmanager
def try_use_cap_kill() -> Generator[bool, None, None]:
"""
A context-manager that attempts to leverage the CAP_KILL Linux capability.

If CAP_KILL is in the current thread's effective set, this context-manager takes no action and
yields True.

If CAP_KILL is not in the effective set but is in the permitted set, the context-manager:
1. adds CAP_KILL to the effective set before entering the context-manager
2. yields True
3. clears CAP_KILL from the effective set when exiting the context-manager

Otherwise, the context-manager does nothing and yields False

Returns:
A context manager that yields a bool. See above for details.
"""
if not sys.platform.startswith("linux"):
raise OSError(f"Only Linux is supported, but platform is {sys.platform}")

libcap = _get_libcap()
# If libcap is not found, we yield False indicating we are not aware of having CAP_KILL
if not libcap:
yield False
return

caps = libcap.cap_get_proc()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cap_get_proc() returns a heap-allocated cap_t that the caller owns and must release with cap_free() (see the cap_get_proc(3) man page). This code never calls cap_free(caps) on any of its exit paths, so every invocation of try_use_cap_kill() leaks the capability state object. Since this context manager is entered on each action cancellation, the leak accumulates over the lifetime of a long-running session host.

Consider binding libcap.cap_free and freeing caps in a finally (and note that cap_set_flag/cap_set_proc were registered without an errcheck, so failures there are silently ignored — unlike the get-path functions).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cap_get_proc() allocates a cap_t on the heap that must be released with cap_free() (per the libcap man page: the caller should free releasable memory with cap_free()). Here caps is never freed in any of the three branches that follow, so every call to try_use_cap_kill() leaks one capability-state object. Since this context manager runs on each action cancel/signal, the leak accumulates over the lifetime of a long-running session.

Consider declaring libcap.cap_free (argtype cap_t, restype c_int) in _get_libcap() and calling it on caps from a finally that wraps the body below.


if _has_capability(
libcap=libcap,
caps=caps,
capability=CAP_KILL,
capability_set_type=CapabilitySetType.EFFECTIVE,
):
LOG.debug("CAP_KILL is in the thread's effective set")
# CAP_KILL is already in the effective set
yield True
elif _has_capability(
libcap=libcap,
caps=caps,
capability=CAP_KILL,
capability_set_type=CapabilitySetType.PERMITTED,
):
# CAP_KILL is in the permitted set. We will temporarily add it to the effective set
LOG.debug("CAP_KILL is in the thread's permitted set. Temporarily adding to effective set")
cap_value_arr_t = cap_value_t * 1
cap_value_arr = cap_value_arr_t()
cap_value_arr[0] = CAP_KILL
libcap.cap_set_flag(
caps,
CAP_EFFECTIVE,
1,
cap_value_arr,
CAP_SET,
)
libcap.cap_set_proc(caps)
try:
yield True
finally:
# Clear CAP_KILL from the effective set
LOG.debug("Clearing CAP_KILL from the thread's effective set")
libcap.cap_set_flag(
caps,
CAP_EFFECTIVE,
1,
cap_value_arr,
CAP_CLEAR,
)
libcap.cap_set_proc(caps)
else:
yield False


def main() -> None:
"""A developer debugging entrypoint for testing the try_use_cap_kill() behaviour"""
import logging

logging.basicConfig(level=logging.DEBUG)
logging.getLogger("openjd.sessions").setLevel(logging.DEBUG)

with try_use_cap_kill() as has_cap_kill:
LOG.info("Has CAP_KILL: %s", has_cap_kill)


if __name__ == "__main__":
main()
Loading
Loading