sysrepo-python-plugind 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sysrepo_python_plugind/__init__.py +15 -0
- sysrepo_python_plugind/__main__.py +8 -0
- sysrepo_python_plugind/cli.py +158 -0
- sysrepo_python_plugind/daemon.py +306 -0
- sysrepo_python_plugind/install_yang.py +82 -0
- sysrepo_python_plugind/pid.py +92 -0
- sysrepo_python_plugind/plugin.py +56 -0
- sysrepo_python_plugind/sort.py +91 -0
- sysrepo_python_plugind/yang/sysrepo-python-plugind.yang +54 -0
- sysrepo_python_plugind-1.0.0.dist-info/METADATA +200 -0
- sysrepo_python_plugind-1.0.0.dist-info/RECORD +15 -0
- sysrepo_python_plugind-1.0.0.dist-info/WHEEL +5 -0
- sysrepo_python_plugind-1.0.0.dist-info/entry_points.txt +3 -0
- sysrepo_python_plugind-1.0.0.dist-info/licenses/LICENSE +28 -0
- sysrepo_python_plugind-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Copyright (c) 2026 EntryPoint Communications, LLC
|
|
2
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
3
|
+
|
|
4
|
+
"""sysrepo-python-plugind — Python plugin daemon for sysrepo.
|
|
5
|
+
|
|
6
|
+
Public API:
|
|
7
|
+
|
|
8
|
+
- :class:`SysrepoPlugin`: Abstract base class for Python plugins.
|
|
9
|
+
- :func:`main`: Entry point for the ``sysrepo-python-plugind`` console script.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from .cli import main
|
|
13
|
+
from .plugin import SysrepoPlugin
|
|
14
|
+
|
|
15
|
+
__all__ = ["SysrepoPlugin", "main"]
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# Copyright (c) 2026 EntryPoint Communications, LLC
|
|
2
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
import sysrepo
|
|
11
|
+
|
|
12
|
+
from .daemon import PluginDaemon
|
|
13
|
+
from .pid import PidFile
|
|
14
|
+
|
|
15
|
+
# Map sysrepo-plugind -v levels to Python logging levels, matching the C daemon.
|
|
16
|
+
_LOG_LEVELS = {
|
|
17
|
+
0: logging.ERROR,
|
|
18
|
+
1: logging.WARNING,
|
|
19
|
+
2: logging.INFO,
|
|
20
|
+
3: logging.DEBUG,
|
|
21
|
+
4: logging.DEBUG,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def main() -> int:
|
|
26
|
+
"""Parse CLI arguments and run the sysrepo Python plugin daemon.
|
|
27
|
+
|
|
28
|
+
Parses ``-d``, ``-v``, ``-p``, and ``-f`` flags (mirroring the C
|
|
29
|
+
sysrepo-plugind), configures logging, creates a PluginDaemon, and
|
|
30
|
+
either daemonizes or runs in the foreground.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
int: Exit code; 0 on success, 1 on error.
|
|
34
|
+
"""
|
|
35
|
+
parser = argparse.ArgumentParser(
|
|
36
|
+
prog="sysrepo-python-plugind",
|
|
37
|
+
description="Sysrepo Python plugin daemon",
|
|
38
|
+
)
|
|
39
|
+
parser.add_argument(
|
|
40
|
+
"-d",
|
|
41
|
+
dest="debug",
|
|
42
|
+
action="store_true",
|
|
43
|
+
help="debug mode: stay in foreground, log to stderr",
|
|
44
|
+
)
|
|
45
|
+
parser.add_argument(
|
|
46
|
+
"-v",
|
|
47
|
+
dest="verbosity",
|
|
48
|
+
type=int,
|
|
49
|
+
default=2,
|
|
50
|
+
metavar="LEVEL",
|
|
51
|
+
choices=range(5),
|
|
52
|
+
help="log verbosity 0-4 (default: 2 = INFO)",
|
|
53
|
+
)
|
|
54
|
+
parser.add_argument(
|
|
55
|
+
"-p",
|
|
56
|
+
dest="pid_file",
|
|
57
|
+
metavar="PATH",
|
|
58
|
+
help="write PID file at PATH",
|
|
59
|
+
)
|
|
60
|
+
parser.add_argument(
|
|
61
|
+
"-f",
|
|
62
|
+
dest="fatal_fail",
|
|
63
|
+
action="store_true",
|
|
64
|
+
help="exit if any plugin fails to initialise",
|
|
65
|
+
)
|
|
66
|
+
args = parser.parse_args()
|
|
67
|
+
|
|
68
|
+
log_level = _LOG_LEVELS.get(args.verbosity, logging.DEBUG)
|
|
69
|
+
_configure_logging(debug=args.debug, level=log_level)
|
|
70
|
+
|
|
71
|
+
daemon = PluginDaemon(fatal_fail=args.fatal_fail)
|
|
72
|
+
|
|
73
|
+
if args.pid_file:
|
|
74
|
+
return _run_with_pid(daemon, args.pid_file, debug=args.debug)
|
|
75
|
+
|
|
76
|
+
if not args.debug:
|
|
77
|
+
_daemonize()
|
|
78
|
+
return daemon.run()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ------------------------------------------------------------------------------
|
|
82
|
+
def _run_with_pid(daemon: PluginDaemon, pid_path: str, debug: bool) -> int:
|
|
83
|
+
"""Run the daemon with a PID file, handling lock failure gracefully.
|
|
84
|
+
|
|
85
|
+
Opens the PID file lock before daemonizing so that a second invocation
|
|
86
|
+
fails immediately with a clear error message.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
daemon (PluginDaemon): Configured daemon instance to run.
|
|
90
|
+
pid_path (str): Filesystem path for the PID file.
|
|
91
|
+
debug (bool): If True, skip daemonization and stay in foreground.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
int: Exit code from daemon.run(), or 1 if the PID file is already
|
|
95
|
+
locked by another instance.
|
|
96
|
+
"""
|
|
97
|
+
try:
|
|
98
|
+
with PidFile(pid_path) as pid_file:
|
|
99
|
+
if not debug:
|
|
100
|
+
_daemonize()
|
|
101
|
+
return daemon.run(pid_file)
|
|
102
|
+
except RuntimeError as exc:
|
|
103
|
+
# PID file lock failure — another instance is running.
|
|
104
|
+
logging.getLogger(__name__).error("%s", exc)
|
|
105
|
+
return 1
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _configure_logging(debug: bool, level: int) -> None:
|
|
109
|
+
"""Configure Python and sysrepo logging.
|
|
110
|
+
|
|
111
|
+
In debug mode logs to stderr; otherwise routes to syslog under the
|
|
112
|
+
application name ``'sysrepo-python-plugind'``.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
debug (bool): If True, log to stderr; otherwise log to syslog.
|
|
116
|
+
level (int): Python logging level (e.g. ``logging.INFO``).
|
|
117
|
+
"""
|
|
118
|
+
handler = logging.StreamHandler()
|
|
119
|
+
handler.setLevel(level)
|
|
120
|
+
logging.getLogger().addHandler(handler)
|
|
121
|
+
logging.getLogger().setLevel(level)
|
|
122
|
+
|
|
123
|
+
if debug:
|
|
124
|
+
sysrepo.configure_logging(stderr_level=level, py_logging=True)
|
|
125
|
+
else:
|
|
126
|
+
sysrepo.configure_logging(
|
|
127
|
+
syslog_level=level,
|
|
128
|
+
syslog_app_name="sysrepo-python-plugind",
|
|
129
|
+
py_logging=True,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _daemonize() -> None:
|
|
134
|
+
"""Daemonize the current process using the standard double-fork technique.
|
|
135
|
+
|
|
136
|
+
After the second fork the process is in its own session, has no
|
|
137
|
+
controlling terminal, and has stdin/stdout/stderr redirected to
|
|
138
|
+
/dev/null. File descriptors beyond stderr are left open so that a
|
|
139
|
+
PidFile lock opened before this call survives in the child process.
|
|
140
|
+
"""
|
|
141
|
+
# First fork: become session leader's child.
|
|
142
|
+
pid = os.fork()
|
|
143
|
+
if pid > 0:
|
|
144
|
+
sys.exit(0)
|
|
145
|
+
|
|
146
|
+
os.setsid()
|
|
147
|
+
|
|
148
|
+
# Second fork: ensure we can never re-acquire a controlling terminal.
|
|
149
|
+
pid = os.fork()
|
|
150
|
+
if pid > 0:
|
|
151
|
+
sys.exit(0)
|
|
152
|
+
|
|
153
|
+
os.chdir("/")
|
|
154
|
+
|
|
155
|
+
devnull = os.open(os.devnull, os.O_RDWR)
|
|
156
|
+
for fd in (sys.stdin.fileno(), sys.stdout.fileno(), sys.stderr.fileno()):
|
|
157
|
+
os.dup2(devnull, fd)
|
|
158
|
+
os.close(devnull)
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
# Copyright (c) 2026 EntryPoint Communications, LLC
|
|
2
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import contextlib
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import signal
|
|
9
|
+
import socket
|
|
10
|
+
import sys
|
|
11
|
+
import threading
|
|
12
|
+
from importlib.metadata import entry_points
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
import sysrepo
|
|
16
|
+
|
|
17
|
+
from .pid import PidFile
|
|
18
|
+
from .plugin import SysrepoPlugin
|
|
19
|
+
from .sort import PluginList, sort_plugins
|
|
20
|
+
|
|
21
|
+
LOG = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
_LOADED_XPATH = (
|
|
24
|
+
"/sysrepo-plugind:sysrepo-plugind"
|
|
25
|
+
"/sysrepo-python-plugind:python-loaded-plugins/plugin"
|
|
26
|
+
)
|
|
27
|
+
_LOADED_CONTAINER = (
|
|
28
|
+
"/sysrepo-plugind:sysrepo-plugind"
|
|
29
|
+
"/sysrepo-python-plugind:python-loaded-plugins"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class PluginDaemon:
|
|
34
|
+
"""Python equivalent of sysrepo-plugind.
|
|
35
|
+
|
|
36
|
+
Loads Python plugins via entry points, manages their lifecycle (init,
|
|
37
|
+
run, cleanup), and publishes loaded-plugin state to the sysrepo
|
|
38
|
+
operational datastore.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
fatal_fail (bool): If True, a plugin init() failure aborts the
|
|
42
|
+
entire daemon. If False (default), the failing plugin is
|
|
43
|
+
skipped and the remaining plugins are attempted.
|
|
44
|
+
|
|
45
|
+
Example::
|
|
46
|
+
|
|
47
|
+
daemon = PluginDaemon(fatal_fail=False)
|
|
48
|
+
with PidFile("/run/srpy-plugind.pid") as pid_file:
|
|
49
|
+
_daemonize()
|
|
50
|
+
rc = daemon.run(pid_file)
|
|
51
|
+
sys.exit(rc)
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(self, fatal_fail: bool = False) -> None:
|
|
55
|
+
self.fatal_fail = fatal_fail
|
|
56
|
+
self._stop = threading.Event()
|
|
57
|
+
self._plugins: PluginList = []
|
|
58
|
+
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
|
59
|
+
self._loop_thread: Optional[threading.Thread] = None
|
|
60
|
+
|
|
61
|
+
# ------------------------------------------------------------------
|
|
62
|
+
# Public entry point
|
|
63
|
+
|
|
64
|
+
def run(self, pid_file: Optional[PidFile] = None) -> int:
|
|
65
|
+
"""Run the daemon; block until a termination signal is received.
|
|
66
|
+
|
|
67
|
+
Opens a sysrepo connection, initialises all discovered plugins,
|
|
68
|
+
publishes the loaded-plugins list to the operational datastore,
|
|
69
|
+
notifies systemd, and then waits for a stop signal before cleaning
|
|
70
|
+
up.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
pid_file (PidFile, optional): If provided, write() is called
|
|
74
|
+
after all plugins are initialised so the file contains the
|
|
75
|
+
daemonized child's PID.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
int: 0 on clean shutdown, 1 if a fatal error occurred.
|
|
79
|
+
"""
|
|
80
|
+
self._setup_signals()
|
|
81
|
+
self._start_event_loop()
|
|
82
|
+
|
|
83
|
+
rc = 0
|
|
84
|
+
try:
|
|
85
|
+
with sysrepo.SysrepoConnection() as conn:
|
|
86
|
+
with conn.start_session("running") as sess:
|
|
87
|
+
self._init_plugins(sess)
|
|
88
|
+
self._publish_loaded(sess)
|
|
89
|
+
if pid_file is not None:
|
|
90
|
+
pid_file.write()
|
|
91
|
+
_sd_notify("READY=1")
|
|
92
|
+
LOG.info(
|
|
93
|
+
"daemon ready, %d plugin(s) loaded", len(self._plugins)
|
|
94
|
+
)
|
|
95
|
+
self._stop.wait()
|
|
96
|
+
LOG.info("shutting down")
|
|
97
|
+
_sd_notify("STOPPING=1")
|
|
98
|
+
self._cleanup_plugins(sess)
|
|
99
|
+
except Exception:
|
|
100
|
+
LOG.exception("daemon error")
|
|
101
|
+
rc = 1
|
|
102
|
+
finally:
|
|
103
|
+
self._stop_event_loop()
|
|
104
|
+
|
|
105
|
+
return rc
|
|
106
|
+
|
|
107
|
+
# ------------------------------------------------------------------
|
|
108
|
+
# Signal handling
|
|
109
|
+
|
|
110
|
+
def _setup_signals(self) -> None:
|
|
111
|
+
"""Register signal handlers matching the C sysrepo-plugind behaviour.
|
|
112
|
+
|
|
113
|
+
SIGINT, SIGTERM, SIGQUIT, SIGABRT, and SIGHUP trigger a graceful
|
|
114
|
+
shutdown. A second signal while shutdown is already in progress
|
|
115
|
+
calls sys.exit(1). SIGPIPE, SIGTSTP, SIGTTIN, and SIGTTOU are
|
|
116
|
+
ignored.
|
|
117
|
+
"""
|
|
118
|
+
def _handler(sig: int, _frame) -> None:
|
|
119
|
+
if self._stop.is_set():
|
|
120
|
+
# Second signal while already shutting down → hard exit.
|
|
121
|
+
sys.exit(1)
|
|
122
|
+
LOG.info("received %s, initiating shutdown", signal.Signals(sig).name)
|
|
123
|
+
self._stop.set()
|
|
124
|
+
|
|
125
|
+
for sig in (
|
|
126
|
+
signal.SIGINT,
|
|
127
|
+
signal.SIGTERM,
|
|
128
|
+
signal.SIGQUIT,
|
|
129
|
+
signal.SIGABRT,
|
|
130
|
+
signal.SIGHUP,
|
|
131
|
+
):
|
|
132
|
+
signal.signal(sig, _handler)
|
|
133
|
+
|
|
134
|
+
for sig in (signal.SIGPIPE, signal.SIGTSTP, signal.SIGTTIN, signal.SIGTTOU):
|
|
135
|
+
signal.signal(sig, signal.SIG_IGN)
|
|
136
|
+
|
|
137
|
+
# ------------------------------------------------------------------
|
|
138
|
+
# asyncio event loop
|
|
139
|
+
|
|
140
|
+
def _start_event_loop(self) -> None:
|
|
141
|
+
"""Create and start an asyncio event loop in a background daemon thread.
|
|
142
|
+
|
|
143
|
+
The loop is set as the thread-local event loop via
|
|
144
|
+
asyncio.set_event_loop() before any plugin init() calls, so plugins
|
|
145
|
+
that use asyncio_register=True subscriptions can rely on
|
|
146
|
+
asyncio.get_event_loop() returning a running loop without managing
|
|
147
|
+
one themselves.
|
|
148
|
+
"""
|
|
149
|
+
self._loop = asyncio.new_event_loop()
|
|
150
|
+
# Make the loop visible to asyncio.get_event_loop() in plugin init().
|
|
151
|
+
asyncio.set_event_loop(self._loop)
|
|
152
|
+
self._loop_thread = threading.Thread(
|
|
153
|
+
target=self._loop.run_forever,
|
|
154
|
+
name="sysrepo-asyncio",
|
|
155
|
+
daemon=True,
|
|
156
|
+
)
|
|
157
|
+
self._loop_thread.start()
|
|
158
|
+
LOG.debug("asyncio event loop started")
|
|
159
|
+
|
|
160
|
+
def _stop_event_loop(self) -> None:
|
|
161
|
+
"""Signal the asyncio event loop to stop and join its thread.
|
|
162
|
+
|
|
163
|
+
Safe to call if the loop was never started. Waits up to 5 seconds
|
|
164
|
+
for the loop thread to exit before returning.
|
|
165
|
+
"""
|
|
166
|
+
if self._loop and not self._loop.is_closed():
|
|
167
|
+
self._loop.call_soon_threadsafe(self._loop.stop)
|
|
168
|
+
if self._loop_thread:
|
|
169
|
+
self._loop_thread.join(timeout=5.0)
|
|
170
|
+
LOG.debug("asyncio event loop stopped")
|
|
171
|
+
|
|
172
|
+
# ------------------------------------------------------------------
|
|
173
|
+
# Plugin lifecycle
|
|
174
|
+
|
|
175
|
+
def _discover_plugins(self) -> PluginList:
|
|
176
|
+
"""Discover all installed plugins via the sysrepo_python.plugins entry point group.
|
|
177
|
+
|
|
178
|
+
Loads each entry point, verifies it is a SysrepoPlugin subclass,
|
|
179
|
+
and instantiates it.
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
PluginList: (entry-point-name, instance) pairs in discovery order.
|
|
183
|
+
|
|
184
|
+
Raises:
|
|
185
|
+
Exception: Re-raised from ep.load() or instantiation when
|
|
186
|
+
fatal_fail is True; otherwise logged and skipped.
|
|
187
|
+
"""
|
|
188
|
+
discovered: PluginList = []
|
|
189
|
+
for ep in entry_points(group="sysrepo_python.plugins"):
|
|
190
|
+
try:
|
|
191
|
+
cls = ep.load()
|
|
192
|
+
if not (isinstance(cls, type) and issubclass(cls, SysrepoPlugin)):
|
|
193
|
+
raise TypeError(
|
|
194
|
+
f"{ep.value!r} must be a SysrepoPlugin subclass, got {cls!r}"
|
|
195
|
+
)
|
|
196
|
+
discovered.append((ep.name, cls()))
|
|
197
|
+
LOG.debug("discovered plugin %r (%s)", ep.name, ep.value)
|
|
198
|
+
except Exception:
|
|
199
|
+
LOG.exception("failed to load entry point %r", ep.name)
|
|
200
|
+
if self.fatal_fail:
|
|
201
|
+
raise
|
|
202
|
+
return discovered
|
|
203
|
+
|
|
204
|
+
def _init_plugins(self, sess: sysrepo.session.SysrepoSession) -> None:
|
|
205
|
+
"""Discover, sort, and initialise all plugins.
|
|
206
|
+
|
|
207
|
+
Calls _discover_plugins(), reorders the result via sort_plugins(),
|
|
208
|
+
then calls init() on each plugin. Successfully initialised plugins
|
|
209
|
+
are appended to self._plugins.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
sess (sysrepo.session.SysrepoSession): Active running-datastore session
|
|
213
|
+
passed to each plugin's init().
|
|
214
|
+
|
|
215
|
+
Raises:
|
|
216
|
+
Exception: Re-raised from plugin init() when fatal_fail is True;
|
|
217
|
+
otherwise logged and the plugin is skipped.
|
|
218
|
+
"""
|
|
219
|
+
plugins = self._discover_plugins()
|
|
220
|
+
plugins = sort_plugins(sess, plugins)
|
|
221
|
+
|
|
222
|
+
for ep_name, inst in plugins:
|
|
223
|
+
try:
|
|
224
|
+
inst.init(sess)
|
|
225
|
+
self._plugins.append((ep_name, inst))
|
|
226
|
+
LOG.info("initialized plugin %r", ep_name)
|
|
227
|
+
except Exception:
|
|
228
|
+
LOG.exception("plugin %r init() failed", ep_name)
|
|
229
|
+
if self.fatal_fail:
|
|
230
|
+
raise
|
|
231
|
+
|
|
232
|
+
def _cleanup_plugins(self, sess: sysrepo.session.SysrepoSession) -> None:
|
|
233
|
+
"""Call cleanup() on all initialised plugins in reverse init order.
|
|
234
|
+
|
|
235
|
+
Exceptions from individual cleanup() calls are logged but do not
|
|
236
|
+
prevent the remaining plugins from being cleaned up.
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
sess (sysrepo.session.SysrepoSession): Active running-datastore session
|
|
240
|
+
passed to each plugin's cleanup().
|
|
241
|
+
"""
|
|
242
|
+
for ep_name, inst in reversed(self._plugins):
|
|
243
|
+
try:
|
|
244
|
+
inst.cleanup(sess)
|
|
245
|
+
LOG.info("cleaned up plugin %r", ep_name)
|
|
246
|
+
except Exception:
|
|
247
|
+
LOG.exception("plugin %r cleanup() raised", ep_name)
|
|
248
|
+
|
|
249
|
+
# ------------------------------------------------------------------
|
|
250
|
+
# Operational datastore
|
|
251
|
+
|
|
252
|
+
def _publish_loaded(self, sess: sysrepo.session.SysrepoSession) -> None:
|
|
253
|
+
"""Publish initialised plugin names to the sysrepo operational datastore.
|
|
254
|
+
|
|
255
|
+
Switches to the operational datastore, clears any stale entries
|
|
256
|
+
under the python-loaded-plugins container, writes one leaf-list
|
|
257
|
+
entry per successfully initialised plugin, applies changes, then
|
|
258
|
+
switches back to the running datastore.
|
|
259
|
+
|
|
260
|
+
Args:
|
|
261
|
+
sess (sysrepo.session.SysrepoSession): Active sysrepo session;
|
|
262
|
+
temporarily switched to operational and back to running.
|
|
263
|
+
|
|
264
|
+
Raises:
|
|
265
|
+
sysrepo.SysrepoNotFoundError: Caught internally when clearing
|
|
266
|
+
stale entries on first run; not propagated.
|
|
267
|
+
"""
|
|
268
|
+
sess.switch_datastore("operational")
|
|
269
|
+
try:
|
|
270
|
+
sess.discard_items(_LOADED_CONTAINER)
|
|
271
|
+
except sysrepo.SysrepoNotFoundError:
|
|
272
|
+
pass
|
|
273
|
+
|
|
274
|
+
for ep_name, _ in self._plugins:
|
|
275
|
+
sess.set_item(_LOADED_XPATH, ep_name)
|
|
276
|
+
LOG.info("add plugin %r to operational datastore", ep_name)
|
|
277
|
+
|
|
278
|
+
sess.apply_changes()
|
|
279
|
+
LOG.info("operational store update complete")
|
|
280
|
+
sess.switch_datastore("running")
|
|
281
|
+
LOG.debug(
|
|
282
|
+
"published %d plugin name(s) to operational datastore",
|
|
283
|
+
len(self._plugins),
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
# ------------------------------------------------------------------------------
|
|
288
|
+
def _sd_notify(state: str) -> None:
|
|
289
|
+
"""Send a state notification to systemd via the NOTIFY_SOCKET.
|
|
290
|
+
|
|
291
|
+
Does nothing if the NOTIFY_SOCKET environment variable is not set.
|
|
292
|
+
Suppresses OSError (e.g. if the socket path is stale or invalid).
|
|
293
|
+
|
|
294
|
+
Args:
|
|
295
|
+
state (str): Notification string, e.g. ``'READY=1'`` or
|
|
296
|
+
``'STOPPING=1'``.
|
|
297
|
+
"""
|
|
298
|
+
sock_path = os.environ.get("NOTIFY_SOCKET", "")
|
|
299
|
+
if not sock_path:
|
|
300
|
+
return
|
|
301
|
+
# systemd may use an abstract socket (leading '@' means NUL byte).
|
|
302
|
+
addr = "\0" + sock_path[1:] if sock_path.startswith("@") else sock_path
|
|
303
|
+
with contextlib.suppress(OSError):
|
|
304
|
+
with socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) as s:
|
|
305
|
+
s.connect(addr)
|
|
306
|
+
s.sendall(state.encode())
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# Copyright (c) 2026 EntryPoint Communications, LLC
|
|
2
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
3
|
+
|
|
4
|
+
"""Install or upgrade the sysrepo-python-plugind YANG module into sysrepo."""
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from importlib.resources import as_file, files
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
_MODULE_NAME = "sysrepo-python-plugind"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def main() -> int:
|
|
16
|
+
"""Install or upgrade the sysrepo-python-plugind YANG augmentation module.
|
|
17
|
+
|
|
18
|
+
Checks whether the module is already installed via ``sysrepoctl --list``
|
|
19
|
+
and either skips (already installed, no ``--force``), installs (first
|
|
20
|
+
run), or upgrades (``--force``) using ``sysrepoctl``.
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
int: 0 on success or when already installed, non-zero if
|
|
24
|
+
``sysrepoctl`` exits with an error.
|
|
25
|
+
"""
|
|
26
|
+
parser = argparse.ArgumentParser(
|
|
27
|
+
prog="sysrepo-python-plugind-setup",
|
|
28
|
+
description="Install the sysrepo-python-plugind YANG augmentation module.",
|
|
29
|
+
)
|
|
30
|
+
parser.add_argument(
|
|
31
|
+
"--force",
|
|
32
|
+
action="store_true",
|
|
33
|
+
help="upgrade the module even if it is already installed",
|
|
34
|
+
)
|
|
35
|
+
args = parser.parse_args()
|
|
36
|
+
|
|
37
|
+
already_installed = _is_installed()
|
|
38
|
+
|
|
39
|
+
if not args.force and already_installed:
|
|
40
|
+
print(f"{_MODULE_NAME}: already installed, nothing to do")
|
|
41
|
+
print(" (use --force to upgrade)")
|
|
42
|
+
return 0
|
|
43
|
+
|
|
44
|
+
flag = "--update" if already_installed else "--install"
|
|
45
|
+
verb = "Upgrading" if already_installed else "Installing"
|
|
46
|
+
|
|
47
|
+
yang_ref = files("sysrepo_python_plugind").joinpath(
|
|
48
|
+
"yang/sysrepo-python-plugind.yang"
|
|
49
|
+
)
|
|
50
|
+
with as_file(yang_ref) as yang_path:
|
|
51
|
+
print(f"{verb} {yang_path} ...")
|
|
52
|
+
result = subprocess.run(
|
|
53
|
+
["sysrepoctl", flag, str(yang_path)],
|
|
54
|
+
check=False,
|
|
55
|
+
)
|
|
56
|
+
if result.returncode != 0:
|
|
57
|
+
print(
|
|
58
|
+
f"sysrepoctl {flag} failed (exit {result.returncode})",
|
|
59
|
+
file=sys.stderr,
|
|
60
|
+
)
|
|
61
|
+
return result.returncode
|
|
62
|
+
|
|
63
|
+
print(f"{_MODULE_NAME}: module installed successfully")
|
|
64
|
+
return 0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _is_installed() -> bool:
|
|
68
|
+
"""Check whether the sysrepo-python-plugind YANG module is installed.
|
|
69
|
+
|
|
70
|
+
Runs ``sysrepoctl --list`` and searches the output for the module name.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
bool: True if the module appears in ``sysrepoctl`` output, False
|
|
74
|
+
otherwise or if ``sysrepoctl`` itself fails.
|
|
75
|
+
"""
|
|
76
|
+
result = subprocess.run(
|
|
77
|
+
["sysrepoctl", "--list"],
|
|
78
|
+
capture_output=True,
|
|
79
|
+
text=True,
|
|
80
|
+
check=False,
|
|
81
|
+
)
|
|
82
|
+
return _MODULE_NAME in result.stdout
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# Copyright (c) 2026 EntryPoint Communications, LLC
|
|
2
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
3
|
+
|
|
4
|
+
import fcntl
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
LOG = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PidFile:
|
|
12
|
+
"""POSIX PID file with exclusive advisory lock.
|
|
13
|
+
|
|
14
|
+
Matches the C sysrepo-plugind behaviour:
|
|
15
|
+
|
|
16
|
+
- Open and lock **before** daemonizing so a second invocation fails
|
|
17
|
+
immediately, even before the first instance writes its PID.
|
|
18
|
+
- :meth:`write` is called **after** plugins have initialised so the
|
|
19
|
+
file contains the daemonized child's PID, not the parent's.
|
|
20
|
+
- On exit the file is closed and unlinked.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
path (str): Filesystem path for the PID file.
|
|
24
|
+
|
|
25
|
+
Example::
|
|
26
|
+
|
|
27
|
+
with PidFile("/run/sysrepo-python-plugind.pid") as pid_file:
|
|
28
|
+
_daemonize()
|
|
29
|
+
# ... init plugins ...
|
|
30
|
+
pid_file.write()
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
__slots__ = ("path", "_fd")
|
|
34
|
+
|
|
35
|
+
def __init__(self, path: str) -> None:
|
|
36
|
+
self.path = path
|
|
37
|
+
self._fd: int | None = None
|
|
38
|
+
|
|
39
|
+
def __enter__(self) -> "PidFile":
|
|
40
|
+
"""Open and exclusively lock the PID file.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
PidFile: self, for use as a context manager.
|
|
44
|
+
|
|
45
|
+
Raises:
|
|
46
|
+
RuntimeError: If the exclusive lock cannot be acquired because
|
|
47
|
+
another instance of the daemon already holds it.
|
|
48
|
+
"""
|
|
49
|
+
self._fd = os.open(self.path, os.O_RDWR | os.O_CREAT, 0o640)
|
|
50
|
+
try:
|
|
51
|
+
fcntl.lockf(self._fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
52
|
+
except OSError as exc:
|
|
53
|
+
os.close(self._fd)
|
|
54
|
+
self._fd = None
|
|
55
|
+
raise RuntimeError(
|
|
56
|
+
f"another sysrepo-python-plugind instance is running "
|
|
57
|
+
f"(cannot lock {self.path})"
|
|
58
|
+
) from exc
|
|
59
|
+
return self
|
|
60
|
+
|
|
61
|
+
def write(self) -> None:
|
|
62
|
+
"""Write the current process PID to the file.
|
|
63
|
+
|
|
64
|
+
Truncates any existing content before writing so the file always
|
|
65
|
+
contains exactly one line. Should be called after daemonizing so
|
|
66
|
+
the written PID belongs to the final child process, not the
|
|
67
|
+
pre-fork parent.
|
|
68
|
+
"""
|
|
69
|
+
if self._fd is None:
|
|
70
|
+
return
|
|
71
|
+
pid_bytes = f"{os.getpid()}\n".encode()
|
|
72
|
+
os.ftruncate(self._fd, 0)
|
|
73
|
+
os.lseek(self._fd, 0, os.SEEK_SET)
|
|
74
|
+
os.write(self._fd, pid_bytes)
|
|
75
|
+
LOG.debug("wrote PID %d to %s", os.getpid(), self.path)
|
|
76
|
+
|
|
77
|
+
def __exit__(self, *_) -> None:
|
|
78
|
+
"""Close and unlink the PID file.
|
|
79
|
+
|
|
80
|
+
Silently ignores FileNotFoundError in case the file was already
|
|
81
|
+
removed externally.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
*_: Exception info from the ``with`` block; not used.
|
|
85
|
+
"""
|
|
86
|
+
if self._fd is not None:
|
|
87
|
+
os.close(self._fd)
|
|
88
|
+
self._fd = None
|
|
89
|
+
try:
|
|
90
|
+
os.unlink(self.path)
|
|
91
|
+
except FileNotFoundError:
|
|
92
|
+
pass
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Copyright (c) 2026 EntryPoint Communications, LLC
|
|
2
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
3
|
+
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
|
|
6
|
+
import sysrepo
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SysrepoPlugin(ABC):
|
|
10
|
+
"""Abstract base class for sysrepo Python plugins.
|
|
11
|
+
|
|
12
|
+
Plugins are discovered by sysrepo-python-plugind via the
|
|
13
|
+
``sysrepo_python.plugins`` entry point group. Declare a plugin in
|
|
14
|
+
your package's ``pyproject.toml``::
|
|
15
|
+
|
|
16
|
+
[project.entry-points."sysrepo_python.plugins"]
|
|
17
|
+
my-plugin = "my_package.plugin:MyPlugin"
|
|
18
|
+
|
|
19
|
+
The daemon instantiates the class with no arguments, calls
|
|
20
|
+
:meth:`init` at startup (after plugin ordering), and :meth:`cleanup`
|
|
21
|
+
at shutdown in reverse init order. State that the C daemon stored in
|
|
22
|
+
``void *private_data`` should be stored as instance attributes.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
@abstractmethod
|
|
26
|
+
def init(self, session: sysrepo.session.SysrepoSession) -> None:
|
|
27
|
+
"""Initialise the plugin at daemon startup.
|
|
28
|
+
|
|
29
|
+
Called once after plugin ordering and before ``sd_notify("READY=1")``.
|
|
30
|
+
Register sysrepo subscriptions and allocate resources here. The
|
|
31
|
+
session is on ``SR_DS_RUNNING`` and is shared with all other plugins.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
session (sysrepo.session.SysrepoSession): Active running-datastore
|
|
35
|
+
session shared across all plugins.
|
|
36
|
+
|
|
37
|
+
Raises:
|
|
38
|
+
Exception: Signals rejection of this plugin. With
|
|
39
|
+
``--fatal-plugin-fail`` the daemon exits; otherwise this
|
|
40
|
+
plugin is skipped and the next one is attempted.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def cleanup(self, session: sysrepo.session.SysrepoSession) -> None:
|
|
44
|
+
"""Clean up plugin resources at daemon shutdown.
|
|
45
|
+
|
|
46
|
+
Called once in reverse init order after a stop signal is received.
|
|
47
|
+
Sysrepo subscriptions created on the session are automatically
|
|
48
|
+
released when the session closes, so this method only needs to
|
|
49
|
+
release external resources (file handles, network connections, etc.).
|
|
50
|
+
|
|
51
|
+
The default implementation does nothing.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
session (sysrepo.session.SysrepoSession): Active running-datastore
|
|
55
|
+
session shared across all plugins.
|
|
56
|
+
"""
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# Copyright (c) 2026 EntryPoint Communications, LLC
|
|
2
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
import sysrepo
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from .plugin import SysrepoPlugin
|
|
11
|
+
|
|
12
|
+
LOG = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
_ORDER_XPATH = (
|
|
15
|
+
"/sysrepo-plugind:sysrepo-plugind"
|
|
16
|
+
"/sysrepo-python-plugind:python-plugin-order/plugin"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
# Type alias for the (entry-point-name, instance) pairs the daemon works with.
|
|
20
|
+
PluginList = list[tuple[str, "SysrepoPlugin"]]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def sort_plugins(session: sysrepo.session.SysrepoSession, plugins: PluginList) -> PluginList:
|
|
24
|
+
"""Reorder plugins according to the operator-configured plugin order.
|
|
25
|
+
|
|
26
|
+
Reads the ``python-plugin-order`` leaf-list from the sysrepo running
|
|
27
|
+
datastore and performs a stable partition: configured plugins are moved
|
|
28
|
+
to the front in YANG list order; unconfigured plugins retain their
|
|
29
|
+
original discovery order at the end.
|
|
30
|
+
|
|
31
|
+
Mirrors ``srpd_sort_plugins()`` from
|
|
32
|
+
``sysrepo/src/executables/srpd_common.c``.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
session (sysrepo.session.SysrepoSession): Active running-datastore session
|
|
36
|
+
used to read the python-plugin-order configuration.
|
|
37
|
+
plugins (PluginList): Discovered (entry-point-name, instance) pairs
|
|
38
|
+
in discovery order. The input list is not modified.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
PluginList: Reordered (entry-point-name, instance) pairs.
|
|
42
|
+
Configured plugins appear first in YANG list order; remaining
|
|
43
|
+
plugins follow in their original discovery order.
|
|
44
|
+
|
|
45
|
+
Raises:
|
|
46
|
+
sysrepo.SysrepoNotFoundError: Caught internally when no
|
|
47
|
+
plugin-order is configured; treated as an empty list and the
|
|
48
|
+
original order is returned unchanged.
|
|
49
|
+
"""
|
|
50
|
+
try:
|
|
51
|
+
# String(Value, str) — each item IS a Python str already.
|
|
52
|
+
configured = [_strip_ext(v) for v in session.get_items(_ORDER_XPATH)]
|
|
53
|
+
except sysrepo.SysrepoNotFoundError:
|
|
54
|
+
LOG.debug("no plugin-order configured, using discovery order")
|
|
55
|
+
return plugins
|
|
56
|
+
|
|
57
|
+
if not configured:
|
|
58
|
+
return plugins
|
|
59
|
+
|
|
60
|
+
LOG.debug("plugin-order from sysrepo: %s", configured)
|
|
61
|
+
|
|
62
|
+
ordered: PluginList = []
|
|
63
|
+
remaining = list(plugins)
|
|
64
|
+
|
|
65
|
+
for cfg_name in configured:
|
|
66
|
+
for i, (ep_name, inst) in enumerate(remaining):
|
|
67
|
+
if _strip_ext(ep_name) == cfg_name:
|
|
68
|
+
ordered.append(remaining.pop(i))
|
|
69
|
+
LOG.debug("placed plugin %r at position %d", ep_name, len(ordered))
|
|
70
|
+
break
|
|
71
|
+
# cfg_name not found among loaded plugins — ignore, per C implementation.
|
|
72
|
+
|
|
73
|
+
return ordered + remaining
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _strip_ext(name: str) -> str:
|
|
77
|
+
"""Strip the last file extension from a plugin name, if any.
|
|
78
|
+
|
|
79
|
+
Used to normalise operator-configured names (e.g. ``'my_plugin.so'``)
|
|
80
|
+
to match Python entry-point names (e.g. ``'my_plugin'``), preserving
|
|
81
|
+
compatibility with operator configs written for the C daemon.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
name (str): Plugin name, optionally including a file extension.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
str: Name with the last dot-suffix removed, or the original name
|
|
88
|
+
if no dot is present.
|
|
89
|
+
"""
|
|
90
|
+
dot = name.rfind(".")
|
|
91
|
+
return name[:dot] if dot >= 0 else name
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
module sysrepo-python-plugind {
|
|
2
|
+
yang-version 1.1;
|
|
3
|
+
namespace "urn:sysrepo:python-plugind";
|
|
4
|
+
prefix srpdpy;
|
|
5
|
+
|
|
6
|
+
import sysrepo-plugind {
|
|
7
|
+
prefix srpd;
|
|
8
|
+
revision-date 2022-08-26;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
organization "sysrepo-python-plugind contributors";
|
|
12
|
+
description
|
|
13
|
+
"Augments sysrepo-plugind with Python-daemon-specific configuration
|
|
14
|
+
and state nodes. This allows the Python daemon and the C daemon to
|
|
15
|
+
coexist: each manages only its own subtree and neither overwrites the
|
|
16
|
+
other's operational data.";
|
|
17
|
+
|
|
18
|
+
revision 2026-05-13 {
|
|
19
|
+
description "Initial revision.";
|
|
20
|
+
reference "https://github.com/...";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
augment "/srpd:sysrepo-plugind" {
|
|
24
|
+
description "Python-daemon plugin ordering and loaded-plugin state.";
|
|
25
|
+
|
|
26
|
+
container python-plugin-order {
|
|
27
|
+
description
|
|
28
|
+
"Ordered list of Python plugin entry-point names. Mirrors
|
|
29
|
+
plugin-order from the base module but applies only to the
|
|
30
|
+
Python daemon.";
|
|
31
|
+
leaf-list plugin {
|
|
32
|
+
type string;
|
|
33
|
+
ordered-by user;
|
|
34
|
+
description
|
|
35
|
+
"Entry-point name of a Python plugin. A trailing file
|
|
36
|
+
extension (e.g. '.so') is stripped before comparison,
|
|
37
|
+
preserving compatibility with operator configs written for
|
|
38
|
+
the C daemon.";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
container python-loaded-plugins {
|
|
43
|
+
config false;
|
|
44
|
+
description
|
|
45
|
+
"Names of Python plugins successfully initialised by the running
|
|
46
|
+
Python daemon. Written to the operational datastore after all
|
|
47
|
+
plugins have been initialised.";
|
|
48
|
+
leaf-list plugin {
|
|
49
|
+
type string;
|
|
50
|
+
description "Entry-point name of a loaded Python plugin.";
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sysrepo-python-plugind
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Python plugin daemon for sysrepo (replacement for sysrepo-plugind)
|
|
5
|
+
License-Expression: BSD-3-Clause
|
|
6
|
+
Project-URL: Repository, https://github.com/entrypoint-communications/sysrepo-python-plugind
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/x-rst
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Dist: sysrepo~=2.0
|
|
11
|
+
Provides-Extra: test
|
|
12
|
+
Requires-Dist: pytest>=7; extra == "test"
|
|
13
|
+
Dynamic: license-file
|
|
14
|
+
|
|
15
|
+
sysrepo-python-plugind
|
|
16
|
+
======================
|
|
17
|
+
|
|
18
|
+
A Python reimplementation of ``sysrepo-plugind`` — the sysrepo plugin daemon.
|
|
19
|
+
|
|
20
|
+
The C daemon loads compiled ``.so`` plugins via ``dlopen``. This daemon loads
|
|
21
|
+
**Python plugins** discovered via entry points (``importlib.metadata``), giving
|
|
22
|
+
plugin authors a pure-Python development experience against the existing
|
|
23
|
+
`sysrepo-python`_ CFFI bindings.
|
|
24
|
+
|
|
25
|
+
The two daemons can run **simultaneously**: the Python daemon manages its own
|
|
26
|
+
YANG subtree (``python-plugin-order``, ``python-loaded-plugins``) and never
|
|
27
|
+
touches the C daemon's nodes.
|
|
28
|
+
|
|
29
|
+
.. _sysrepo-python: https://github.com/sysrepo/sysrepo-python
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
Requirements
|
|
33
|
+
------------
|
|
34
|
+
|
|
35
|
+
- Python ≥ 3.10
|
|
36
|
+
- sysrepo-python_ (and the underlying ``libsysrepo.so``)
|
|
37
|
+
- sysrepo core 4.2.10 (ships ``sysrepo-plugind@2022-08-26.yang``)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
Installation
|
|
41
|
+
------------
|
|
42
|
+
|
|
43
|
+
.. code-block:: console
|
|
44
|
+
|
|
45
|
+
pip install sysrepo-python-plugind
|
|
46
|
+
|
|
47
|
+
Then register the YANG augmentation module with sysrepo (requires write
|
|
48
|
+
access to the sysrepo repository, typically run as root):
|
|
49
|
+
|
|
50
|
+
.. code-block:: console
|
|
51
|
+
|
|
52
|
+
sysrepo-python-plugind-setup
|
|
53
|
+
|
|
54
|
+
This is idempotent — safe to run on every deployment or system boot. Pass
|
|
55
|
+
``--force`` to upgrade the module after a package update.
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
Writing a plugin
|
|
59
|
+
----------------
|
|
60
|
+
|
|
61
|
+
Subclass ``SysrepoPlugin`` and implement ``init()``. Register the class via
|
|
62
|
+
an entry point in your package's ``pyproject.toml``:
|
|
63
|
+
|
|
64
|
+
.. code-block:: toml
|
|
65
|
+
|
|
66
|
+
[project.entry-points."sysrepo_python.plugins"]
|
|
67
|
+
my-plugin = "my_package.plugin:MyPlugin"
|
|
68
|
+
|
|
69
|
+
Installing your package makes the plugin visible to the daemon immediately —
|
|
70
|
+
no copying of files or environment variables required.
|
|
71
|
+
|
|
72
|
+
Minimal example:
|
|
73
|
+
|
|
74
|
+
.. code-block:: python
|
|
75
|
+
|
|
76
|
+
import logging
|
|
77
|
+
import sysrepo
|
|
78
|
+
from sysrepo_python_plugind import SysrepoPlugin
|
|
79
|
+
|
|
80
|
+
LOG = logging.getLogger(__name__)
|
|
81
|
+
|
|
82
|
+
class MyPlugin(SysrepoPlugin):
|
|
83
|
+
|
|
84
|
+
def init(self, session: sysrepo.SysrepoSession) -> None:
|
|
85
|
+
self._counter = 0
|
|
86
|
+
session.subscribe_module_change(
|
|
87
|
+
"my-module", None, self._on_change, done_only=True
|
|
88
|
+
)
|
|
89
|
+
session.subscribe_oper_data_request(
|
|
90
|
+
"my-module", "/my-module:stats", self._on_oper
|
|
91
|
+
)
|
|
92
|
+
LOG.info("MyPlugin ready")
|
|
93
|
+
|
|
94
|
+
def cleanup(self, session: sysrepo.SysrepoSession) -> None:
|
|
95
|
+
LOG.info("MyPlugin stopping (counter=%d)", self._counter)
|
|
96
|
+
|
|
97
|
+
def _on_change(self, event, req_id, changes, priv):
|
|
98
|
+
self._counter += 1
|
|
99
|
+
|
|
100
|
+
def _on_oper(self, xpath, priv):
|
|
101
|
+
return {"my-module": {"stats": {"change-count": self._counter}}}
|
|
102
|
+
|
|
103
|
+
State that the C daemon stored in ``void *private_data`` is stored as instance
|
|
104
|
+
attributes (``self._counter`` above).
|
|
105
|
+
|
|
106
|
+
Async callbacks
|
|
107
|
+
~~~~~~~~~~~~~~~
|
|
108
|
+
|
|
109
|
+
The daemon starts an asyncio event loop in a background thread before calling
|
|
110
|
+
any ``init()``, so plugins can use ``asyncio_register=True`` subscriptions
|
|
111
|
+
without managing a loop themselves:
|
|
112
|
+
|
|
113
|
+
.. code-block:: python
|
|
114
|
+
|
|
115
|
+
class AsyncMyPlugin(SysrepoPlugin):
|
|
116
|
+
|
|
117
|
+
def init(self, session: sysrepo.SysrepoSession) -> None:
|
|
118
|
+
session.subscribe_module_change(
|
|
119
|
+
"my-module", None, self._on_change,
|
|
120
|
+
done_only=True, asyncio_register=True,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
async def _on_change(self, event, req_id, changes, priv):
|
|
124
|
+
...
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
Running the daemon
|
|
128
|
+
------------------
|
|
129
|
+
|
|
130
|
+
.. code-block:: console
|
|
131
|
+
|
|
132
|
+
# Foreground, INFO-level logging to stderr:
|
|
133
|
+
sysrepo-python-plugind -d -v 2
|
|
134
|
+
|
|
135
|
+
# Background (daemonize), write PID file:
|
|
136
|
+
sysrepo-python-plugind -p /run/sysrepo-python-plugind.pid
|
|
137
|
+
|
|
138
|
+
CLI flags mirror those of the C ``sysrepo-plugind``:
|
|
139
|
+
|
|
140
|
+
============ ===================================================================
|
|
141
|
+
``-d`` Stay in foreground; log to stderr instead of syslog
|
|
142
|
+
``-v LEVEL`` Verbosity: 0=ERROR 1=WARNING 2=INFO 3=DEBUG (default: 2)
|
|
143
|
+
``-p PATH`` Write PID file at PATH
|
|
144
|
+
``-f`` Exit if any plugin fails to initialise (default: skip and continue)
|
|
145
|
+
============ ===================================================================
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
Plugin ordering
|
|
149
|
+
---------------
|
|
150
|
+
|
|
151
|
+
By default plugins are initialised in entry-point discovery order. To
|
|
152
|
+
override this, set the ``python-plugin-order`` leaf-list in sysrepo's running
|
|
153
|
+
datastore:
|
|
154
|
+
|
|
155
|
+
.. code-block:: console
|
|
156
|
+
|
|
157
|
+
sysrepocfg -d running --edit
|
|
158
|
+
|
|
159
|
+
Add under ``/sysrepo-plugind:sysrepo-plugind``:
|
|
160
|
+
|
|
161
|
+
.. code-block:: xml
|
|
162
|
+
|
|
163
|
+
<python-plugin-order xmlns="urn:sysrepo:python-plugind">
|
|
164
|
+
<plugin>my-plugin</plugin>
|
|
165
|
+
<plugin>other-plugin</plugin>
|
|
166
|
+
</python-plugin-order>
|
|
167
|
+
|
|
168
|
+
Plugins absent from the list are appended after the ordered set, preserving
|
|
169
|
+
their discovery order. A trailing file extension in a configured name (e.g.
|
|
170
|
+
``my-plugin.so``) is stripped before comparison, so operator configs written
|
|
171
|
+
for the C daemon can be reused unchanged.
|
|
172
|
+
|
|
173
|
+
The list of successfully initialised plugins is published to the operational
|
|
174
|
+
datastore at ``/sysrepo-plugind:sysrepo-plugind/sysrepo-python-plugind:python-loaded-plugins/plugin``.
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
Systemd deployment
|
|
178
|
+
------------------
|
|
179
|
+
|
|
180
|
+
A service file is provided in the ``systemd/`` directory of the source
|
|
181
|
+
distribution:
|
|
182
|
+
|
|
183
|
+
.. code-block:: console
|
|
184
|
+
|
|
185
|
+
cp systemd/sysrepo-python-plugind.service /etc/systemd/system/
|
|
186
|
+
systemctl daemon-reload
|
|
187
|
+
systemctl enable --now sysrepo-python-plugind
|
|
188
|
+
|
|
189
|
+
The service runs ``sysrepo-python-plugind-setup`` as ``ExecStartPre=``,
|
|
190
|
+
ensuring the YANG module is present before the daemon starts.
|
|
191
|
+
|
|
192
|
+
If the daemon binary is not at ``/usr/bin/sysrepo-python-plugind`` (e.g. when
|
|
193
|
+
installed into a virtual environment), update the ``ExecStartPre=`` and
|
|
194
|
+
``ExecStart=`` paths in the service file accordingly.
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
License
|
|
198
|
+
-------
|
|
199
|
+
|
|
200
|
+
BSD-3-Clause — same as sysrepo-python.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
sysrepo_python_plugind/__init__.py,sha256=Lo3hxrcCQPfchf5mEglwlR9hrNdGsSDB68wkujX3nIo,414
|
|
2
|
+
sysrepo_python_plugind/__main__.py,sha256=nW9n-CT29lx3_sAo84sNy9AiAejptM5XvSvkLmZJisU,145
|
|
3
|
+
sysrepo_python_plugind/cli.py,sha256=_uqqNqscJH2jrsFE5EmBPFicU_Ea1OFv6JUxJSxu_M8,4625
|
|
4
|
+
sysrepo_python_plugind/daemon.py,sha256=akOafUkE2XugFQ1xkzRymDwAdaG0sTtnbfTJdBm5x8w,11217
|
|
5
|
+
sysrepo_python_plugind/install_yang.py,sha256=hT7i0qfBvL9DzeRrwElWWfPSL_66X7tW6NtxxZGUDaI,2543
|
|
6
|
+
sysrepo_python_plugind/pid.py,sha256=lpgatUUZUZcFm_ik8EJsZ0MqnPwvd_O4euPOrASGEa8,2826
|
|
7
|
+
sysrepo_python_plugind/plugin.py,sha256=SL8D5LX-MBObvARYTcAiO4L6GJsiAmKzvSNt-HwFVEc,2175
|
|
8
|
+
sysrepo_python_plugind/sort.py,sha256=W-uTeqHwHCnVjWYg-mm23QuX7ZLKffo3TOfm29stVd4,3198
|
|
9
|
+
sysrepo_python_plugind/yang/sysrepo-python-plugind.yang,sha256=R122VcgpoZ4jlcaS384ts06Pfr6M70ATGpadk8nrpjg,1673
|
|
10
|
+
sysrepo_python_plugind-1.0.0.dist-info/licenses/LICENSE,sha256=Ci_I22dLniZTPKK9Q_l2vbAzwhnKXjFltyD-AE_T9wI,1516
|
|
11
|
+
sysrepo_python_plugind-1.0.0.dist-info/METADATA,sha256=C2TLhu6KOyb9uyYfOViRvc3W4L2sTPKyTSsYfU2hQZs,6106
|
|
12
|
+
sysrepo_python_plugind-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
13
|
+
sysrepo_python_plugind-1.0.0.dist-info/entry_points.txt,sha256=VuJTp-tPelbpAFCeyasniST9I-jgn6zrdc89Vb43ONY,143
|
|
14
|
+
sysrepo_python_plugind-1.0.0.dist-info/top_level.txt,sha256=k8eY3OJSeYlfOuhzyjr2dSOSGBVp5GgnAHqec-IOtAE,23
|
|
15
|
+
sysrepo_python_plugind-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 EntryPoint Communications, LLC
|
|
4
|
+
|
|
5
|
+
Redistribution and use in source and binary forms, with or without
|
|
6
|
+
modification, are permitted provided that the following conditions are met:
|
|
7
|
+
|
|
8
|
+
1. Redistributions of source code must retain the above copyright notice,
|
|
9
|
+
this list of conditions and the following disclaimer.
|
|
10
|
+
|
|
11
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
and/or other materials provided with the distribution.
|
|
14
|
+
|
|
15
|
+
3. Neither the name of the copyright holder nor the names of its contributors
|
|
16
|
+
may be used to endorse or promote products derived from this software
|
|
17
|
+
without specific prior written permission.
|
|
18
|
+
|
|
19
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
20
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
21
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
22
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
23
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
24
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
25
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
26
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
27
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
28
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
sysrepo_python_plugind
|