diff --git a/_drgn_util/plugins.py b/_drgn_util/plugins.py new file mode 100644 index 000000000..183a58fc4 --- /dev/null +++ b/_drgn_util/plugins.py @@ -0,0 +1,108 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# SPDX-License-Identifier: LGPL-2.1-or-later + +import fnmatch +import importlib # noqa: F401 +import logging +import os +import runpy +import sys +from types import SimpleNamespace +from typing import List, Tuple + +logger = logging.getLogger("drgn.plugins") + +_plugins = None + + +def _load_plugins() -> List[Tuple[str, object]]: + plugins: List[Tuple[str, object]] = [] + enabled_entry_points = {} + + env = os.getenv("DRGN_PLUGINS") + if env: + for item in env.split(","): + if not item: + # Ignore empty items for convenience. + continue + name, sep, value = item.partition("=") + if sep: + try: + if value.startswith("/") or value.startswith("."): + plugin: object = SimpleNamespace(**runpy.run_path(value)) + else: + plugin = importlib.import_module(value) + except Exception: + logger.warning("failed to load %r:", value, exc_info=True) + else: + plugins.append((name, plugin)) + logger.debug("loaded %r", item) + else: + enabled_entry_points[name] = False + + env = os.getenv("DRGN_DISABLE_PLUGINS") + # If all plugins are disabled, avoid the entry point machinery entirely. + if env != "*" or enabled_entry_points: + group = "drgn.plugins" + + if sys.version_info >= (3, 10): + import importlib.metadata # novermin + + entry_points = importlib.metadata.entry_points(group=group) # novermin + elif sys.version_info >= (3, 8): + import importlib.metadata # novermin + + entry_points = importlib.metadata.entry_points()[group] # novermin + else: + import pkg_resources + + entry_points = pkg_resources.iter_entry_points(group) + + disable_plugins = env.split(",") if env else [] + for entry_point in entry_points: + if entry_point.name in enabled_entry_points: + enabled_entry_points[entry_point.name] = True + elif any( + fnmatch.fnmatch(entry_point.name, disable) + for disable in disable_plugins + ): + continue + try: + plugin = entry_point.load() + except Exception: + logger.warning("failed to load %r:", entry_point.value, exc_info=True) + else: + plugins.append((entry_point.name, plugin)) + logger.debug("loaded %r", entry_point.name) + + missing_entry_points = [ + key for key, value in enabled_entry_points.items() if not value + ] + if missing_entry_points: + missing_entry_points.sort() + logger.warning( + "not found: %s", + ", ".join([repr(name) for name in missing_entry_points]), + ) + + plugins.sort( + key=lambda plugin: (getattr(plugin[1], "drgn_priority", 50), plugin[0]) + ) + return plugins + + +def call_plugins(hook_name: str, *args: object) -> None: + global _plugins + if _plugins is None: + _plugins = _load_plugins() + + for name, plugin in _plugins: + try: + hook = getattr(plugin, hook_name) + except AttributeError: + continue + + try: + hook(*args) + except Exception: + logger.warning("%r %s failed:", name, hook_name, exc_info=True) diff --git a/docs/advanced_usage.rst b/docs/advanced_usage.rst index 725f06041..30596310b 100644 --- a/docs/advanced_usage.rst +++ b/docs/advanced_usage.rst @@ -199,11 +199,95 @@ program "memory": :meth:`drgn.Program.register_object_finder()` are the equivalent methods for plugging in types and objects. +.. _writing-plugins: + +Writing Plugins +--------------- + +In order for drgn to load a plugin automatically, it must be registered as an +`entry point `_ for +the ``drgn.plugins`` group. Here is a minimal example. First: + +.. code-block:: console + + $ mkdir drgn_plugin_example + $ cd drgn_plugin_example + +Then, create ``pyproject.toml`` with the following contents: + +.. code-block:: toml + :caption: pyproject.toml + :emphasize-lines: 5-6 + + [project] + name = 'drgn_plugin_example' + version = '0.0.1' + + [project.entry-points.'drgn.plugins'] + example = 'drgn_plugin_example' + +See the `Python Packaging User Guide +`_ for a complete +description of ``pyproject.toml``. We are most interested in the last two +lines, which define the entry point. In ``example = 'drgn_plugin_example'``, +``example`` is the plugin name, and ``drgn_plugin_example`` is the plugin +module. + +Create ``drgn_plugin_example.py`` with the following contents: + +.. code-block:: python3 + :caption: drgn_plugin_example.py + + import drgn + + # Optional; the default is 50. + drgn_priority = 100 + + def example_debug_info_finder(modules: list[drgn.Module]) -> None: + if isinstance(module, drgn.MainModule): + module.try_file("/my/vmlinux") + + def drgn_prog_set(prog: drgn.Program) -> None: + if prog.flags & drgn.ProgramFlags.IS_LINUX_KERNEL: + prog.register_debug_info_finder( + "example", example_debug_info_finder, enable_index=-1 + ) + +This is a typical usage of the :func:`drgn_prog_set()` hook to register +finders. See :ref:`plugins` for more details. + +After creating the above files, the plugin can be installed with +``pip install .``. + Environment Variables --------------------- Some of drgn's behavior can be modified through environment variables: +.. envvar:: DRGN_DISABLE_PLUGINS + + Comma-separated list of plugins to disable. Each item is a glob pattern + matching plugin entry point names. + +.. envvar:: DRGN_PLUGINS + + Comma-separated list of plugins to enable. Each item is either a plugin + entry point name, a file path, or a module name. Empty items are ignored. + + An item not containing ``=`` is interpreted as a plugin entry point name. + This takes precedence over :envvar:`DRGN_DISABLE_PLUGINS`. + + An item containing ``=`` is interpreted as an extra plugin to load manually + instead of via an entry point. The string before ``=`` is the plugin name. + The string after ``=`` is the value. If the value starts with ``.`` or + ``/``, it is the file path of a Python module. Otherwise, it is a module + name. + + So, ``DRGN_DISABLE_PLUGINS=* DRGN_PLUGINS=foo,bar=/hello/world.py,baz=my.module`` + results in three plugins being loaded: the entry point ``foo``, the file + ``/hello/world.py`` as ``bar``, and the module ``my.module`` as ``baz``. + All other plugins are disabled. + .. envvar:: DRGN_MAX_DEBUG_INFO_ERRORS The maximum number of warnings about missing debugging information to log diff --git a/docs/api_reference.rst b/docs/api_reference.rst index eecd0138a..370fd19f7 100644 --- a/docs/api_reference.rst +++ b/docs/api_reference.rst @@ -218,6 +218,40 @@ CLI .. drgndoc:: cli +.. _plugins: + +Plugins +------- + +drgn can be extended with plugins. A drgn plugin is a Python module defining +one or more hook functions that are called at specific times. + +drgn loads plugins before calling a hook for the first time. By default, it +loads installed modules registered as :ref:`entry points ` for +the ``drgn.plugins`` group. The :envvar:`DRGN_DISABLE_PLUGINS` and +:envvar:`DRGN_PLUGINS` environment variables can be used to configure what +plugins are loaded. + +The following hooks are currently defined: + +.. py:currentmodule:: None + +.. function:: drgn_prog_set(prog: drgn.Program) -> None + + Called after the program target has been set (e.g., one of + :meth:`drgn.Program.set_core_dump()`, :meth:`drgn.Program.set_kernel()`, or + :meth:`drgn.Program.set_pid()` has been called). + +Plugins can also define the following settings: + +.. data:: drgn_priority + :type: int + + Defines the order that plugins are called in. Plugins with lower values are + called earlier (e.g., a plugin with ``drgn_priority = 1`` is called before + one with ``drgn_priority = 2``). Plugins with equal values are called in an + unspecified order. The default if not defined is 50. + Logging ------- diff --git a/libdrgn/Makefile.am b/libdrgn/Makefile.am index c66ab6686..857378425 100644 --- a/libdrgn/Makefile.am +++ b/libdrgn/Makefile.am @@ -166,6 +166,7 @@ libdrgnimpl_la_SOURCES += python/constants.c \ python/module_section_addresses.c \ python/object.c \ python/platform.c \ + python/plugins.c \ python/program.c \ python/stack_trace.c \ python/symbol.c \ diff --git a/libdrgn/plugins.h b/libdrgn/plugins.h new file mode 100644 index 000000000..897ec556f --- /dev/null +++ b/libdrgn/plugins.h @@ -0,0 +1,17 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. +// SPDX-License-Identifier: LGPL-2.1-or-later + +#ifndef DRGN_PLUGINS_H +#define DRGN_PLUGINS_H + +#include + +struct drgn_program; + +#if ENABLE_PYTHON +void drgn_call_plugins_prog(const char *name, struct drgn_program *prog); +#else +static inline void drgn_call_plugins_prog(const char *name, struct drgn_program *prog) {} +#endif + +#endif /* DRGN_PLUGINS_H */ diff --git a/libdrgn/program.c b/libdrgn/program.c index 67d6bcde7..00e731d8c 100644 --- a/libdrgn/program.c +++ b/libdrgn/program.c @@ -31,6 +31,7 @@ #include "memory_reader.h" #include "minmax.h" #include "object.h" +#include "plugins.h" #include "program.h" #include "serialize.h" #include "symbol.h" @@ -666,6 +667,7 @@ drgn_program_set_core_dump_fd_internal(struct drgn_program *prog, int fd, goto out_segments; } + drgn_call_plugins_prog("drgn_prog_set", prog); return NULL; out_segments: @@ -769,6 +771,8 @@ drgn_program_set_pid(struct drgn_program *prog, pid_t pid) prog->pid = pid; prog->flags |= DRGN_PROGRAM_IS_LIVE | DRGN_PROGRAM_IS_LOCAL; + + drgn_call_plugins_prog("drgn_prog_set", prog); return NULL; out_segments: diff --git a/libdrgn/python/plugins.c b/libdrgn/python/plugins.c new file mode 100644 index 000000000..2bf2b20a1 --- /dev/null +++ b/libdrgn/python/plugins.c @@ -0,0 +1,32 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. +// SPDX-License-Identifier: LGPL-2.1-or-later + +#include "drgnpy.h" +#include "../plugins.h" + +void drgn_call_plugins_prog(const char *name, struct drgn_program *prog) +{ + PyGILState_guard(); + + static PyObject *call_plugins; + if (!call_plugins) { + _cleanup_pydecref_ PyObject *_drgn_util_plugins_module = + PyImport_ImportModule("_drgn_util.plugins"); + if (!_drgn_util_plugins_module) { + PyErr_WriteUnraisable(NULL); + return; + } + call_plugins = PyObject_GetAttrString(_drgn_util_plugins_module, + "call_plugins"); + if (!call_plugins) { + PyErr_WriteUnraisable(NULL); + return; + } + } + + Program *prog_obj = container_of(prog, Program, prog); + _cleanup_pydecref_ PyObject *res = + PyObject_CallFunction(call_plugins, "sO", name, prog_obj); + if (!res) + PyErr_WriteUnraisable(call_plugins); +}