Coverage for mfplugin/plugin.py: 79%

319 statements  

« prev     ^ index     » next       coverage.py v7.6.1, created at 2024-11-13 15:57 +0000

1import os 

2import hashlib 

3import json 

4from datetime import datetime, timezone 

5import pickle 

6from pathlib import Path 

7import inspect 

8import shutil 

9import socket 

10from gitignore_parser import parse_gitignore 

11from mfutil import BashWrapper, get_unique_hexa_identifier, mkdir_p_or_die, \ 

12 mkdir_p, BashWrapperOrRaise, hash_generator 

13from mfplugin.configuration import Configuration 

14from mfplugin.app import App 

15from mfplugin.extra_daemon import ExtraDaemon 

16from mfplugin.utils import BadPlugin, get_default_plugins_base_dir, \ 

17 layerapi2_label_file_to_plugin_name, validate_plugin_name, \ 

18 CantBuildPlugin, get_current_envs, PluginEnvContextManager, \ 

19 get_configuration_class, get_app_class, get_extra_daemon_class, \ 

20 get_configuration_paths, \ 

21 is_jsonable, layerapi2_label_to_plugin_home, plugin_name_to_layerapi2_label 

22 

23MFEXT_HOME = os.environ.get("MFEXT_HOME", None) 

24MFMODULE_RUNTIME_HOME = os.environ.get('MFMODULE_RUNTIME_HOME', '/tmp') 

25MFMODULE_LOWERCASE = os.environ.get('MFMODULE_LOWERCASE', 'generic') 

26MFMODULE = os.environ.get('MFMODULE', 'GENERIC') 

27SPEC_TEMPLATE = os.path.join(os.path.dirname(os.path.realpath(__file__)), 

28 "plugin.spec") 

29BUID_HOST = os.environ.get('MFHOSTNAME_FULL', socket.gethostname()) 

30 

31 

32class Plugin(object): 

33 

34 def __init__(self, plugins_base_dir, home, 

35 configuration_class=None, 

36 extra_daemon_class=None, 

37 app_class=None, 

38 dont_read_config_overrides=False): 

39 self.configuration_class = get_configuration_class(configuration_class, 

40 Configuration) 

41 """Configuration class.""" 

42 self.app_class = get_app_class(app_class, App) 

43 """App class.""" 

44 self.extra_daemon_class = get_extra_daemon_class(extra_daemon_class, 

45 ExtraDaemon) 

46 """Extra Daemon class.""" 

47 self.home = os.path.abspath(home) 

48 """Plugin home (absolute and normalized string).""" 

49 self.plugins_base_dir = plugins_base_dir \ 

50 if plugins_base_dir is not None else get_default_plugins_base_dir() 

51 """Plugin base directory (string).""" 

52 self.name = self._get_name() 

53 """Plugin name (string).""" 

54 self.is_dev_linked = os.path.islink(os.path.join(self.plugins_base_dir, 

55 self.name)) 

56 """Is the plugin a devlink? (boolean).""" 

57 self._dont_read_config_overrides = dont_read_config_overrides 

58 self._metadata = {} 

59 self._files = None 

60 self.__loaded = False 

61 # FIXME: detect broken symlink 

62 

63 def _get_debug(self): 

64 self.load() 

65 self._load_files() # as it is not included in load() for perfs reasons 

66 res = {x: y for x, y in inspect.getmembers(self) 

67 if is_jsonable(y) and not x.startswith('_')} 

68 res['configuration'] = self.configuration._get_debug() 

69 return res 

70 

71 def _get_name(self): 

72 llfpath = os.path.join(self.home, ".layerapi2_label") 

73 tmp = layerapi2_label_file_to_plugin_name(llfpath) 

74 validate_plugin_name(tmp) 

75 return tmp 

76 

77 def load(self): 

78 if self.__loaded is True: 

79 return 

80 self.__loaded = True 

81 c = self.configuration_class 

82 self._configuration = c( 

83 self.name, self.home, 

84 app_class=self.app_class, 

85 extra_daemon_class=self.extra_daemon_class, 

86 dont_read_config_overrides=self._dont_read_config_overrides 

87 ) 

88 self._layerapi2_layer_name = plugin_name_to_layerapi2_label(self.name) 

89 self._load_format_version() 

90 self._load_metadata() 

91 self._load_version_release() 

92 # self._load_files() is not included here for perfs reasons 

93 

94 def load_full(self): 

95 self.load() 

96 self.configuration.load() 

97 

98 def reload(self): 

99 self.__loaded = False 

100 self.load() 

101 

102 def _load_metadata(self): 

103 if self.is_dev_linked: 

104 self._is_installed = True 

105 self._build_host = "unknown" 

106 self._build_date = "unknown" 

107 self._size = "unknown" 

108 return 

109 metadata_filepath = "%s/%s/.metadata.json" % (self.plugins_base_dir, 

110 self.name) 

111 self._is_installed = os.path.isfile(metadata_filepath) 

112 if self._is_installed: 

113 try: 

114 with open(metadata_filepath, "r") as f: 

115 c = f.read().strip() 

116 except Exception as e: 

117 raise BadPlugin("can't read %s file" % metadata_filepath, 

118 original_exception=e) 

119 try: 

120 self._metadata = json.loads(c) 

121 except Exception as e: 

122 raise BadPlugin("can't decode %s file" % metadata_filepath, 

123 original_exception=e) 

124 self._build_host = self._metadata.get("build_host", "unknown") 

125 self._build_date = self._metadata.get("build_date", "unknown") 

126 self._size = self._metadata.get("size", "unknown") 

127 

128 def get_configuration_hash(self): 

129 args = [] 

130 try: 

131 with open("%s/.layerapi2_dependencies" % self.home, "r") as f: 

132 args.append(f.read()) 

133 except Exception: 

134 pass 

135 for path in get_configuration_paths( 

136 self.name, self.home) + ["/etc/metwork.config"]: 

137 try: 

138 with open(path, "r") as f: 

139 args.append(f.read()) 

140 except Exception: 

141 pass 

142 return hash_generator(*args) 

143 

144 def get_plugin_env_dict(self, add_current_envs=True, 

145 set_tmp_dir=True, 

146 cache=False): 

147 res = self._get_plugin_env_dict(add_current_envs=add_current_envs, 

148 set_tmp_dir=set_tmp_dir, 

149 cache=cache) 

150 # this bloc is here and not inside _get_plugin_env_dict because 

151 # PYTHONPATH shouldn't be cached (because it depends on loaded layers) 

152 if self.configuration.add_plugin_dir_to_python_path: 

153 old_python_path = os.environ.get("PYTHONPATH", None) 

154 if old_python_path: 

155 res["PYTHONPATH"] = self.home + ":" + old_python_path 

156 else: 

157 res["PYTHONPATH"] = self.home 

158 return res 

159 

160 def _get_plugin_env_dict(self, add_current_envs=True, 

161 set_tmp_dir=True, 

162 cache=False): 

163 if cache: 

164 if not set_tmp_dir or not add_current_envs: 

165 raise Exception( 

166 "cache=True is not compatible with add_current_envs=False " 

167 "or set_tmp_dir=False") 

168 try: 

169 with open("%s/.configuration_cache" % self.home, "rb") as f: 

170 h, res = pickle.loads(f.read()) 

171 if h == self.get_configuration_hash(): 

172 res["%s_CURRENT_PLUGIN_CACHE" % MFMODULE] = "1" 

173 tmpdir = res["TMPDIR"] 

174 if tmpdir != "" and not os.path.exists(tmpdir): 

175 mkdir_p(tmpdir, nodebug=True, nowarning=True) 

176 return res 

177 except Exception: 

178 pass 

179 lines = [] 

180 res = {} 

181 try: 

182 # FIXME: shoud be better to parse this file in layerapi2 

183 with open("%s/.layerapi2_dependencies" % self.home, "r") as f: 

184 lines = f.readlines() 

185 except Exception: 

186 pass 

187 for line in lines: 

188 tmp = line.strip() 

189 if tmp.startswith('-'): 

190 tmp = tmp[1:] 

191 if tmp.startswith("plugin_"): 

192 home = layerapi2_label_to_plugin_home(self.plugins_base_dir, 

193 tmp) 

194 if home is None: 

195 continue 

196 try: 

197 p = Plugin(self.plugins_base_dir, home) 

198 p.load() 

199 except Exception: 

200 continue 

201 res.update(p.get_plugin_env_dict(add_current_envs=False)) 

202 env_var_dict = self.configuration.get_configuration_env_dict( 

203 ignore_keys_starting_with="_") 

204 res.update(env_var_dict) 

205 if add_current_envs: 

206 res.update(get_current_envs(self.name, self.home)) 

207 if set_tmp_dir: 

208 tmpdir = os.path.join(MFMODULE_RUNTIME_HOME, "tmp", self.name) 

209 if mkdir_p(tmpdir, nodebug=True, nowarning=True): 

210 res["TMPDIR"] = tmpdir 

211 if cache: 

212 h = self.get_configuration_hash() 

213 tmpname = "%s/.configuration_cache.%s" % \ 

214 (self.home, get_unique_hexa_identifier()) 

215 with open(tmpname, "wb") as f: 

216 f.write(pickle.dumps([h, res])) 

217 os.rename(tmpname, "%s/.configuration_cache" % self.home) 

218 Path('%s/.configuration_cache' % self.home).touch() 

219 return res 

220 

221 def plugin_env_context(self, **kwargs): 

222 return PluginEnvContextManager(self.get_plugin_env_dict(**kwargs)) 

223 

224 def _load_version_release(self): 

225 if not self._is_installed: 

226 # the plugin is not installed, let's read version in configuration 

227 self._version = self.configuration.version 

228 if self.configuration.release: 

229 self._release = self.configuration.release 

230 else: 

231 self._release = "1" 

232 return 

233 # the plugin is installed 

234 if self.is_dev_linked: 

235 # this is a devlink 

236 self._version = "dev_link" 

237 self._release = "dev_link" 

238 return 

239 self._version = self._metadata["version"] 

240 self._release = self._metadata["release"] 

241 

242 def _load_format_version(self): 

243 pfv = os.path.join(self.home, ".plugin_format_version") 

244 if not os.path.isfile(pfv): 

245 raise BadPlugin("%s is missing => this is probably an old and " 

246 "incompatible plugin => please migrate it!" % pfv) 

247 try: 

248 with open("%s/.plugin_format_version" % self.home, "r") as f: 

249 c = f.read().strip() 

250 tmp = c.split('.') 

251 if len(tmp) < 3: 

252 raise Exception() 

253 except Exception: 

254 raise BadPlugin("bad %s/.plugin_format_version format!" % 

255 self.home) 

256 res = [] 

257 for t in tmp: 

258 try: 

259 res.append(int(t)) 

260 except Exception: 

261 res.append(9999) 

262 self._format_version = res[0:3] 

263 

264 def print_dangerous_state(self): 

265 res = BashWrapper("_plugins.is_dangerous %s" % (self.name,)) 

266 if res and res.stdout and len(res.stdout) > 0: 

267 print(res.stdout) 

268 

269 def get_hash(self): 

270 sid = ", ".join([self.build_host, self.build_date, self.size, 

271 self.version, self.release]) 

272 return hashlib.md5(sid.encode('utf8')).hexdigest() 

273 

274 def repackage(self): 

275 self.load() 

276 tmpdir = os.path.join(MFMODULE_RUNTIME_HOME, "tmp", 

277 "plugin_%s" % get_unique_hexa_identifier()) 

278 mkdir_p_or_die(tmpdir) 

279 shutil.copytree(self.home, os.path.join(tmpdir, "metwork_plugin"), 

280 symlinks=True) 

281 

282 def build(self): 

283 self.load() 

284 pwd = os.getcwd() 

285 tmpdir = os.path.join(MFMODULE_RUNTIME_HOME, "tmp", 

286 "plugin_%s" % get_unique_hexa_identifier()) 

287 filename = f"{self.name}-{self.version}-{self.release}." \ 

288 f"metwork.{MFMODULE_LOWERCASE}.plugin" 

289 mkdir_p_or_die(tmpdir) 

290 shutil.copytree(self.home, os.path.join(tmpdir, "metwork_plugin"), 

291 symlinks=True) 

292 matches = None 

293 ignore_filepath = os.path.join(self.home, ".releaseignore") 

294 if os.path.isfile(ignore_filepath): 

295 try: 

296 matches = parse_gitignore(ignore_filepath) 

297 except Exception as e: 

298 raise BadPlugin("bad %s file" % ignore_filepath, 

299 original_exception=e) 

300 root = os.path.join(tmpdir, "metwork_plugin") 

301 if matches is not None: 

302 for r, d, f in os.walk(root, topdown=False): 

303 for fle in f: 

304 full_path = os.path.join(r, fle) 

305 path = self.home + full_path[len(root):] 

306 if matches(path): 

307 try: 

308 os.unlink(full_path) 

309 except Exception: 

310 pass 

311 for flder in d: 

312 full_path = os.path.join(r, flder) 

313 path = self.home + full_path[len(root):] 

314 if matches(path) and not os.listdir(full_path): 

315 shutil.rmtree(full_path, ignore_errors=True) 

316 files = [] 

317 total_size = 0 

318 for r, d, f in os.walk(os.path.join(tmpdir, "metwork_plugin")): 

319 for fle in f: 

320 path = os.path.join(r, fle) 

321 files.append("metwork_plugin/" + path[len(root) + 1:]) 

322 if not os.path.islink(path): 

323 total_size = total_size + os.path.getsize(path) 

324 with open("%s/metwork_plugin/.files.json" % tmpdir, "w") as f: 

325 f.write(json.dumps(files, indent=4)) 

326 

327 # utcnow() is deprecated and should be replaced by now(datetime.UTC) 

328 # (for python >= 3.11) 

329 try: 

330 build_date = datetime.now(timezone.utc).replace( 

331 tzinfo=None).isoformat()[0:19] + 'Z' 

332 except Exception: 

333 build_date = datetime.utcnow().isoformat()[0:19] + 'Z' 

334 

335 metadata = { 

336 "version": self.version, 

337 "release": self.release, 

338 "build_host": BUID_HOST, 

339 "build_date": build_date, 

340 "size": str(total_size), 

341 "summary": self.configuration.summary, 

342 "license": self.configuration.license, 

343 "packager": self.configuration.packager, 

344 "vendor": self.configuration.vendor, 

345 "url": self.configuration.url 

346 } 

347 with open("%s/metwork_plugin/.metadata.json" % tmpdir, "w") as f: 

348 f.write(json.dumps(metadata, indent=4)) 

349 plugin_path = os.path.abspath(f"{pwd}/{filename}") 

350 cmd = f"cd {tmpdir} && tar -cvf plugin.tar metwork_plugin && " \ 

351 f"gzip -f plugin.tar && " \ 

352 f"mv plugin.tar.gz {plugin_path}" 

353 BashWrapperOrRaise(cmd, CantBuildPlugin) 

354 if not os.path.isfile(plugin_path): 

355 raise CantBuildPlugin("can't find plugin file: %s" % plugin_path) 

356 shutil.rmtree(tmpdir, True) 

357 return plugin_path 

358 

359 def _load_files(self): 

360 if self._files is not None: 

361 return 

362 if self.is_dev_linked: 

363 self._files = [] 

364 return 

365 if not self.is_installed: 

366 self._files = [] 

367 return 

368 filepath = "%s/%s/.files.json" % (self.plugins_base_dir, self.name) 

369 if not os.path.isfile(filepath): 

370 raise BadPlugin("%s is missing" % filepath) 

371 try: 

372 with open(filepath, "r") as f: 

373 c = f.read().strip() 

374 except Exception as e: 

375 raise BadPlugin("can't read %s file" % filepath, 

376 original_exception=e) 

377 try: 

378 self._files = json.loads(c) 

379 except Exception as e: 

380 raise BadPlugin("can't decode %s file" % filepath, 

381 original_exception=e) 

382 

383 @property 

384 def configuration(self): 

385 self.load() 

386 return self._configuration 

387 

388 @property 

389 def layerapi2_layer_name(self): 

390 self.load() 

391 return self._layerapi2_layer_name 

392 

393 @property 

394 def format_version(self): 

395 self.load() 

396 return self._format_version 

397 

398 @property 

399 def version(self): 

400 self.load() 

401 return self._version 

402 

403 @property 

404 def release(self): 

405 self.load() 

406 return self._release 

407 

408 @property 

409 def build_host(self): 

410 self.load() 

411 return self._build_host 

412 

413 @property 

414 def build_date(self): 

415 self.load() 

416 return self._build_date 

417 

418 @property 

419 def size(self): 

420 self.load() 

421 return self._size 

422 

423 @property 

424 def is_installed(self): 

425 self.load() 

426 return self._is_installed 

427 

428 @property 

429 def files(self): 

430 self.load() 

431 self._load_files() # not included in load() for perfs reasons 

432 return self._files