Coverage for mfplugin/manager.py: 62%

214 statements  

« 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 

23 

24__pdoc__ = { 

25 "with_lock": False 

26} 

27MFMODULE_RUNTIME_HOME = os.environ.get("MFMODULE_RUNTIME_HOME", "/tmp") 

28LOGGER = None 

29 

30 

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 

37 

38 

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 

57 

58 

59class PluginsManager(object): 

60 

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 

79 

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) 

86 

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) 

93 

94 def plugin_env_context(self, name, **kwargs): 

95 return self.plugins[name].plugin_env_context(**kwargs) 

96 

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) 

109 

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) 

122 

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) 

151 

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) 

160 

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 

177 

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) 

211 

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) 

222 

223 @with_lock 

224 def install_plugin(self, plugin_filepath, new_name=None): 

225 """Install a plugin from a .plugin file. 

226 

227 Args: 

228 plugin_filepath (string): the plugin file path. 

229 new_name (string): alternate plugin name if specified. 

230 

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. 

235 

236 """ 

237 self._install_plugin(plugin_filepath, new_name=new_name) 

238 

239 @with_lock 

240 def uninstall_plugin(self, name): 

241 """Uninstall a plugin. 

242 

243 Args: 

244 name (string): the plugin name to uninstall. 

245 

246 Raises: 

247 NotInstalledPlugin: if the plugin is not installed 

248 CantUninstallPlugin: if the plugin can't be uninstalled. 

249 

250 """ 

251 self._uninstall_plugin(name) 

252 

253 @with_lock 

254 def develop_plugin(self, plugin_home): 

255 """Install a plugin in development mode. 

256 

257 Args: 

258 plugin_path (string): the plugin path to install. 

259 

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. 

264 

265 """ 

266 self._develop_plugin(plugin_home) 

267 

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() 

317 

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 

335 

336 def load_full(self): 

337 self.load() 

338 [x.load_full() for x in self.plugins.values()] 

339 

340 @property 

341 def plugins(self): 

342 self.load() 

343 return self._plugins