Coverage for mfplugin/manager.py: 62%
214 statements
« prev ^ index » next coverage.py v7.6.1, created at 2024-11-13 15:57 +0000
« prev ^ index » next coverage.py v7.6.1, created at 2024-11-13 15:57 +0000
1import os
2import sys
3import tarfile
4import filelock
5import shutil
6import glob
7from functools import wraps
8from mfutil import mkdir_p_or_die, BashWrapperOrRaise
9from mfutil import get_unique_hexa_identifier
10import configupdater
11from mfplugin.plugin import Plugin
12from mfplugin.configuration import Configuration
13from mfplugin.app import App
14from mfplugin.extra_daemon import ExtraDaemon
15from mfplugin.file import PluginFile
16from mfplugin.utils import get_default_plugins_base_dir, \
17 BadPlugin, plugin_name_to_layerapi2_label, \
18 NotInstalledPlugin, AlreadyInstalledPlugin, CantInstallPlugin, \
19 CantUninstallPlugin, \
20 _touch_conf_monitor_control_file, get_plugin_lock_path, \
21 get_extra_daemon_class, get_app_class, get_configuration_class, \
22 layerapi2_label_to_plugin_home, PluginEnvContextManager
24__pdoc__ = {
25 "with_lock": False
26}
27MFMODULE_RUNTIME_HOME = os.environ.get("MFMODULE_RUNTIME_HOME", "/tmp")
28LOGGER = None
31def get_logger(*args, **kwargs):
32 global LOGGER
33 from mflog import get_logger as real_get_logger
34 if LOGGER is None:
35 LOGGER = real_get_logger("mfplugin.manager")
36 return LOGGER
39def with_lock(f):
40 @wraps(f)
41 def wrapper(*args, **kwargs):
42 lock_path = get_plugin_lock_path()
43 # to avoid an INFO message of the filelock library
44 # we call get_logger() here to setup the mflog configuration
45 get_logger()
46 lock = filelock.FileLock(lock_path, timeout=10)
47 try:
48 with lock.acquire(poll_interval=1):
49 res = f(*args, **kwargs)
50 _touch_conf_monitor_control_file()
51 return res
52 except filelock.Timeout:
53 get_logger().warning("can't acquire plugin management lock "
54 " => another plugins.install/uninstall "
55 "running ?")
56 return wrapper
59class PluginsManager(object):
61 def __init__(self, plugins_base_dir=None,
62 configuration_class=None,
63 app_class=None,
64 extra_daemon_class=ExtraDaemon):
65 self.configuration_class = get_configuration_class(configuration_class,
66 Configuration)
67 """Configuration class."""
68 self.app_class = get_app_class(app_class, App)
69 """App class."""
70 self.extra_daemon_class = get_extra_daemon_class(extra_daemon_class,
71 ExtraDaemon)
72 """ExtraDaemon class."""
73 self.plugins_base_dir = plugins_base_dir \
74 if plugins_base_dir is not None else get_default_plugins_base_dir()
75 """Plugin base directory (string)."""
76 if not os.path.isdir(self.plugins_base_dir):
77 mkdir_p_or_die(self.plugins_base_dir)
78 self.__loaded = False
80 def make_plugin(self, plugin_home, dont_read_config_overrides=False):
81 return Plugin(self.plugins_base_dir, plugin_home,
82 configuration_class=self.configuration_class,
83 app_class=self.app_class,
84 extra_daemon_class=self.extra_daemon_class,
85 dont_read_config_overrides=dont_read_config_overrides)
87 def get_plugin(self, name):
88 label = plugin_name_to_layerapi2_label(name)
89 home = layerapi2_label_to_plugin_home(self.plugins_base_dir, label)
90 if home is None:
91 raise NotInstalledPlugin("plugin: %s not installed" % name)
92 return self.make_plugin(home)
94 def plugin_env_context(self, name, **kwargs):
95 return self.plugins[name].plugin_env_context(**kwargs)
97 def _preuninstall_plugin(self, plugin):
98 if shutil.which("_plugins.preuninstall"):
99 env_context = {
100 "MFMODULE_PLUGINS_BASE_DIR": self.plugins_base_dir
101 }
102 # FIXME: should be python methods and not shell
103 with PluginEnvContextManager(env_context):
104 x = BashWrapperOrRaise(
105 "_plugins.preuninstall %s %s %s" %
106 (plugin.name, plugin.version, plugin.release))
107 if len(x.stderr) != 0:
108 print(x.stderr, file=sys.stderr)
110 def _postinstall_plugin(self, plugin):
111 if shutil.which("_plugins.postinstall"):
112 env_context = {
113 "MFMODULE_PLUGINS_BASE_DIR": self.plugins_base_dir
114 }
115 # FIXME: should be python methods and not shell
116 with PluginEnvContextManager(env_context):
117 x = BashWrapperOrRaise(
118 "_plugins.postinstall %s %s %s" %
119 (plugin.name, plugin.version, plugin.release))
120 if len(x.stderr) != 0:
121 print(x.stderr, file=sys.stderr)
123 def _uninstall_plugin(self, name):
124 p = self.get_plugin(name)
125 preuninstall_exception = None
126 try:
127 self._preuninstall_plugin(p)
128 except Exception as e:
129 preuninstall_exception = e
130 # we keep the exception but we want to continue to remove the
131 # plugin
132 if p.is_dev_linked:
133 os.unlink(p.home)
134 else:
135 shutil.rmtree(p.home, ignore_errors=True)
136 self.__loaded = False
137 try:
138 self.get_plugin(name)
139 except NotInstalledPlugin:
140 pass
141 else:
142 raise CantUninstallPlugin("can't uninstall plugin: %s" % name)
143 if os.path.exists(p.home):
144 raise CantUninstallPlugin("can't uninstall plugin: %s "
145 "(directory still here)" % name)
146 if preuninstall_exception is not None:
147 raise CantUninstallPlugin(
148 "the plugin is uninstalled but we "
149 "found some problems during preuninstall script",
150 original_exception=preuninstall_exception)
152 def __before_install_develop(self, name):
153 try:
154 self.get_plugin(name)
155 except NotInstalledPlugin:
156 pass
157 else:
158 raise AlreadyInstalledPlugin("plugin: %s is already installed" %
159 name)
161 def __after_install_develop(self, name):
162 try:
163 p = self.get_plugin(name)
164 except NotInstalledPlugin:
165 raise CantInstallPlugin("can't install plugin %s" % name)
166 try:
167 # check plugin validity (configuration...)
168 p.load_full()
169 # execute postinstall
170 self._postinstall_plugin(p)
171 except Exception:
172 try:
173 self._uninstall_plugin(p.name)
174 except Exception:
175 pass
176 raise
178 def _install_plugin(self, plugin_filepath, new_name=None):
179 x = PluginFile(plugin_filepath)
180 x.load()
181 self.__before_install_develop(new_name if new_name is not None
182 else x.name)
183 if new_name is not None:
184 name = new_name
185 else:
186 name = x.name
187 try:
188 tf = tarfile.open(plugin_filepath, "r")
189 # extractall without filter is deprecated for Python >= 3.12
190 # Filter doesn't exist for Python <= 3.8 (it works as
191 # "fully_trusted")
192 # Default filter in Python 3.14 will be "data"
193 # See https://peps.python.org/pep-0706/
194 try:
195 tf.extractall(self.plugins_base_dir, filter="fully_trusted")
196 except Exception:
197 tf.extractall(self.plugins_base_dir)
198 os.rename(os.path.join(self.plugins_base_dir, "metwork_plugin"),
199 os.path.join(self.plugins_base_dir, name))
200 except Exception as e:
201 raise CantInstallPlugin("can't install plugin %s" % x.name,
202 original_exception=e)
203 if new_name:
204 lalpath = os.path.join(self.plugins_base_dir, name,
205 ".layerapi2_label")
206 with open(lalpath, "w") as f:
207 f.write(plugin_name_to_layerapi2_label(new_name) + "\n")
208 self.__loaded = False
209 self.__after_install_develop(new_name if new_name is not None
210 else x.name)
212 def _develop_plugin(self, plugin_home):
213 p = self.make_plugin(plugin_home)
214 self.__before_install_develop(p.name)
215 shutil.rmtree(os.path.join(self.plugins_base_dir, p.name), True)
216 try:
217 os.symlink(p.home, os.path.join(self.plugins_base_dir, p.name))
218 except OSError:
219 pass
220 self.__loaded = False
221 self.__after_install_develop(p.name)
223 @with_lock
224 def install_plugin(self, plugin_filepath, new_name=None):
225 """Install a plugin from a .plugin file.
227 Args:
228 plugin_filepath (string): the plugin file path.
229 new_name (string): alternate plugin name if specified.
231 Raises:
232 BadPluginFile: if the .plugin file is not found or a bad one.
233 AlreadyInstalledPlugin: if the plugin is already installed.
234 CantInstallPlugin: if the plugin can't be installed.
236 """
237 self._install_plugin(plugin_filepath, new_name=new_name)
239 @with_lock
240 def uninstall_plugin(self, name):
241 """Uninstall a plugin.
243 Args:
244 name (string): the plugin name to uninstall.
246 Raises:
247 NotInstalledPlugin: if the plugin is not installed
248 CantUninstallPlugin: if the plugin can't be uninstalled.
250 """
251 self._uninstall_plugin(name)
253 @with_lock
254 def develop_plugin(self, plugin_home):
255 """Install a plugin in development mode.
257 Args:
258 plugin_path (string): the plugin path to install.
260 Raises:
261 AlreadyInstalledPlugin: if the plugin is already installed.
262 BadPlugin: if the provided plugin is bad.
263 CantInstallPlugin: if the plugin can't be installed.
265 """
266 self._develop_plugin(plugin_home)
268 def repackage_plugin(self, name):
269 p = self.get_plugin(name)
270 p.load_full()
271 if p.is_dev_linked:
272 raise Exception("can't repackage a devlinked plugin")
273 tmpdir = os.path.join(MFMODULE_RUNTIME_HOME, "tmp",
274 "plugin_%s" % get_unique_hexa_identifier())
275 shutil.copytree(p.home, tmpdir, symlinks=True)
276 # FIXME: ? clean ?
277 newp = self.make_plugin(tmpdir, dont_read_config_overrides=True)
278 newp.load_full()
279 x = configupdater.ConfigUpdater(delimiters=('=',),
280 comment_prefixes=('#',))
281 x.optionxform = str
282 x.read("%s/config.ini" % tmpdir)
283 sections = p.configuration._doc.keys()
284 for section in sections:
285 for option in p.configuration._doc[section].keys():
286 if option.startswith('_'):
287 continue
288 val = p.configuration._doc[section][option]
289 try:
290 newval = newp.configuration._doc[section][option]
291 except Exception:
292 # probably a new section
293 try:
294 x.add_section(section)
295 except Exception:
296 pass
297 newval = None
298 try:
299 if newval is None:
300 print("NEW [%s]/%s = %s" % (section, option, val),
301 file=sys.stderr)
302 else:
303 if newval == val:
304 continue
305 print("CHANGED [%s]/%s: %s => %s" %
306 (section, option, newval, val),
307 file=sys.stderr)
308 if isinstance(val, bool):
309 x.set(section, option, "1" if val else "0")
310 else:
311 x.set(section, option, val)
312 except Exception:
313 pass
314 x.update_file()
315 new_p = self.make_plugin(tmpdir)
316 return new_p.build()
318 def load(self):
319 if self.__loaded:
320 return
321 self.__loaded = True
322 self._plugins = {}
323 for directory in glob.glob(os.path.join(self.plugins_base_dir, "*")):
324 dname = os.path.basename(directory)
325 if dname == "base":
326 # special directory (not a plugin one)
327 continue
328 try:
329 plugin = self.make_plugin(directory)
330 except BadPlugin as e:
331 get_logger().warning("found bad plugin in %s => ignoring it "
332 "(details: %s)" % (directory, e))
333 continue
334 self._plugins[plugin.name] = plugin
336 def load_full(self):
337 self.load()
338 [x.load_full() for x in self.plugins.values()]
340 @property
341 def plugins(self):
342 self.load()
343 return self._plugins