Coverage for .tox/coverage/lib/python3.11/site-packages/wuttaweb/views/upgrades.py: 100%

165 statements  

« prev     ^ index     » next       coverage.py v7.6.1, created at 2024-08-26 14:40 -0500

1# -*- coding: utf-8; -*- 

2################################################################################ 

3# 

4# wuttaweb -- Web App for Wutta Framework 

5# Copyright © 2024 Lance Edgar 

6# 

7# This file is part of Wutta Framework. 

8# 

9# Wutta Framework is free software: you can redistribute it and/or modify it 

10# under the terms of the GNU General Public License as published by the Free 

11# Software Foundation, either version 3 of the License, or (at your option) any 

12# later version. 

13# 

14# Wutta Framework is distributed in the hope that it will be useful, but 

15# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 

16# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for 

17# more details. 

18# 

19# You should have received a copy of the GNU General Public License along with 

20# Wutta Framework. If not, see <http://www.gnu.org/licenses/>. 

21# 

22################################################################################ 

23""" 

24Upgrade Views 

25""" 

26 

27import datetime 

28import logging 

29import os 

30import shutil 

31import subprocess 

32 

33from sqlalchemy import orm 

34 

35from wuttjamaican.db.model import Upgrade 

36from wuttaweb.views import MasterView 

37from wuttaweb.forms import widgets 

38from wuttaweb.forms.schema import UserRef, WuttaEnum, FileDownload 

39from wuttaweb.progress import get_progress_session 

40 

41 

42log = logging.getLogger(__name__) 

43 

44 

45class UpgradeView(MasterView): 

46 """ 

47 Master view for upgrades. 

48 

49 Default route prefix is ``upgrades``. 

50 

51 Notable URLs provided by this class: 

52 

53 * ``/upgrades/`` 

54 * ``/upgrades/new`` 

55 * ``/upgrades/XXX`` 

56 * ``/upgrades/XXX/edit`` 

57 * ``/upgrades/XXX/delete`` 

58 """ 

59 model_class = Upgrade 

60 executable = True 

61 execute_progress_template = '/upgrade.mako' 

62 downloadable = True 

63 configurable = True 

64 

65 grid_columns = [ 

66 'created', 

67 'description', 

68 'status', 

69 'executed', 

70 'executed_by', 

71 ] 

72 

73 sort_defaults = ('created', 'desc') 

74 

75 def configure_grid(self, g): 

76 """ """ 

77 super().configure_grid(g) 

78 model = self.app.model 

79 enum = self.app.enum 

80 

81 # description 

82 g.set_link('description') 

83 

84 # created 

85 g.set_renderer('created', self.grid_render_datetime) 

86 

87 # created_by 

88 g.set_link('created_by') 

89 Creator = orm.aliased(model.User) 

90 g.set_joiner('created_by', lambda q: q.join(Creator, 

91 Creator.uuid == model.Upgrade.created_by_uuid)) 

92 g.set_filter('created_by', Creator.username, 

93 label="Created By Username") 

94 

95 # status 

96 g.set_renderer('status', self.grid_render_enum, enum=enum.UpgradeStatus) 

97 

98 # executed 

99 g.set_renderer('executed', self.grid_render_datetime) 

100 

101 # executed_by 

102 g.set_link('executed_by') 

103 Executor = orm.aliased(model.User) 

104 g.set_joiner('executed_by', lambda q: q.outerjoin(Executor, 

105 Executor.uuid == model.Upgrade.executed_by_uuid)) 

106 g.set_filter('executed_by', Executor.username, 

107 label="Executed By Username") 

108 

109 def grid_row_class(self, upgrade, data, i): 

110 """ """ 

111 enum = self.app.enum 

112 if upgrade.status == enum.UpgradeStatus.EXECUTING: 

113 return 'has-background-warning' 

114 if upgrade.status == enum.UpgradeStatus.FAILURE: 

115 return 'has-background-warning' 

116 

117 def configure_form(self, f): 

118 """ """ 

119 super().configure_form(f) 

120 enum = self.app.enum 

121 upgrade = f.model_instance 

122 

123 # never show these 

124 f.remove('created_by_uuid', 

125 'executing', 

126 'executed_by_uuid') 

127 

128 # sequence sanity 

129 f.fields.set_sequence([ 

130 'description', 

131 'notes', 

132 'status', 

133 'created', 

134 'created_by', 

135 'executed', 

136 'executed_by', 

137 ]) 

138 

139 # created 

140 if self.creating or self.editing: 

141 f.remove('created') 

142 

143 # created_by 

144 if self.creating or self.editing: 

145 f.remove('created_by') 

146 else: 

147 f.set_node('created_by', UserRef(self.request)) 

148 

149 # notes 

150 f.set_widget('notes', widgets.NotesWidget()) 

151 

152 # status 

153 if self.creating: 

154 f.remove('status') 

155 else: 

156 f.set_node('status', WuttaEnum(self.request, enum.UpgradeStatus)) 

157 

158 # executed 

159 if self.creating or self.editing or not upgrade.executed: 

160 f.remove('executed') 

161 

162 # executed_by 

163 if self.creating or self.editing or not upgrade.executed: 

164 f.remove('executed_by') 

165 else: 

166 f.set_node('executed_by', UserRef(self.request)) 

167 

168 # exit_code 

169 if self.creating or self.editing or not upgrade.executed: 

170 f.remove('exit_code') 

171 

172 # stdout / stderr 

173 if not (self.creating or self.editing) and upgrade.status in ( 

174 enum.UpgradeStatus.SUCCESS, enum.UpgradeStatus.FAILURE): 

175 

176 # stdout_file 

177 f.append('stdout_file') 

178 f.set_label('stdout_file', "STDOUT") 

179 url = self.get_action_url('download', upgrade, _query={'filename': 'stdout.log'}) 

180 f.set_node('stdout_file', FileDownload(self.request, url=url)) 

181 f.set_default('stdout_file', self.get_upgrade_filepath(upgrade, 'stdout.log')) 

182 

183 # stderr_file 

184 f.append('stderr_file') 

185 f.set_label('stderr_file', "STDERR") 

186 url = self.get_action_url('download', upgrade, _query={'filename': 'stderr.log'}) 

187 f.set_node('stderr_file', FileDownload(self.request, url=url)) 

188 f.set_default('stderr_file', self.get_upgrade_filepath(upgrade, 'stderr.log')) 

189 

190 def delete_instance(self, upgrade): 

191 """ 

192 We override this method to delete any files associated with 

193 the upgrade, in addition to deleting the upgrade proper. 

194 """ 

195 path = self.get_upgrade_filepath(upgrade, create=False) 

196 if os.path.exists(path): 

197 shutil.rmtree(path) 

198 

199 super().delete_instance(upgrade) 

200 

201 def objectify(self, form): 

202 """ """ 

203 upgrade = super().objectify(form) 

204 enum = self.app.enum 

205 

206 # set user, status when creating 

207 if self.creating: 

208 upgrade.created_by = self.request.user 

209 upgrade.status = enum.UpgradeStatus.PENDING 

210 

211 return upgrade 

212 

213 def download_path(self, upgrade, filename): 

214 """ """ 

215 if filename: 

216 return self.get_upgrade_filepath(upgrade, filename) 

217 

218 def get_upgrade_filepath(self, upgrade, filename=None, create=True): 

219 """ """ 

220 uuid = upgrade.uuid 

221 path = self.app.get_appdir('data', 'upgrades', uuid[:2], uuid[2:], 

222 create=create) 

223 if filename: 

224 path = os.path.join(path, filename) 

225 return path 

226 

227 def execute_instance(self, upgrade, user, progress=None): 

228 """ 

229 This method runs the actual upgrade. 

230 

231 Default logic will get the script command from config, and run 

232 it via shell in a subprocess. 

233 

234 The ``stdout`` and ``stderr`` streams are captured to separate 

235 log files which are then available to download. 

236 

237 The upgrade itself is marked as "executed" with status of 

238 either ``SUCCESS`` or ``FAILURE``. 

239 """ 

240 enum = self.app.enum 

241 

242 # locate file paths 

243 script = self.config.require(f'{self.app.appname}.upgrades.command') 

244 stdout_path = self.get_upgrade_filepath(upgrade, 'stdout.log') 

245 stderr_path = self.get_upgrade_filepath(upgrade, 'stderr.log') 

246 

247 # record the fact that execution has begun for this upgrade 

248 # nb. this is done in separate session to ensure it sticks, 

249 # but also update local object to reflect the change 

250 with self.app.short_session(commit=True) as s: 

251 alt = s.merge(upgrade) 

252 alt.status = enum.UpgradeStatus.EXECUTING 

253 upgrade.status = enum.UpgradeStatus.EXECUTING 

254 

255 # run the command 

256 log.debug("running upgrade command: %s", script) 

257 with open(stdout_path, 'wb') as stdout: 

258 with open(stderr_path, 'wb') as stderr: 

259 upgrade.exit_code = subprocess.call(script, shell=True, text=True, 

260 stdout=stdout, stderr=stderr) 

261 logger = log.warning if upgrade.exit_code != 0 else log.debug 

262 logger("upgrade command had exit code: %s", upgrade.exit_code) 

263 

264 # declare it complete 

265 upgrade.executed = datetime.datetime.now() 

266 upgrade.executed_by = user 

267 if upgrade.exit_code == 0: 

268 upgrade.status = enum.UpgradeStatus.SUCCESS 

269 else: 

270 upgrade.status = enum.UpgradeStatus.FAILURE 

271 

272 def execute_progress(self): 

273 """ """ 

274 route_prefix = self.get_route_prefix() 

275 upgrade = self.get_instance() 

276 session = get_progress_session(self.request, f'{route_prefix}.execute') 

277 

278 # session has 'complete' flag set when operation is over 

279 if session.get('complete'): 

280 

281 # set a flash msg for user if one is defined. this is the 

282 # time to do it since user is about to get redirected. 

283 msg = session.get('success_msg') 

284 if msg: 

285 self.request.session.flash(msg) 

286 

287 elif session.get('error'): # uh-oh 

288 

289 # set an error flash msg for user. this is the time to do it 

290 # since user is about to get redirected. 

291 msg = session.get('error_msg', "An unspecified error occurred.") 

292 self.request.session.flash(msg, 'error') 

293 

294 # our return value will include all from progress session 

295 data = dict(session) 

296 

297 # add whatever might be new from upgrade process STDOUT 

298 path = self.get_upgrade_filepath(upgrade, filename='stdout.log') 

299 offset = session.get('stdout.offset', 0) 

300 if os.path.exists(path): 

301 size = os.path.getsize(path) - offset 

302 if size > 0: 

303 # with open(path, 'rb') as f: 

304 with open(path) as f: 

305 f.seek(offset) 

306 chunk = f.read(size) 

307 # data['stdout'] = chunk.decode('utf8').replace('\n', '<br />') 

308 data['stdout'] = chunk.replace('\n', '<br />') 

309 session['stdout.offset'] = offset + size 

310 session.save() 

311 

312 return data 

313 

314 def configure_get_simple_settings(self): 

315 """ """ 

316 

317 script = self.config.get(f'{self.app.appname}.upgrades.command') 

318 if not script: 

319 pass 

320 

321 return [ 

322 

323 # basics 

324 {'name': f'{self.app.appname}.upgrades.command', 

325 'default': script}, 

326 

327 ] 

328 

329 @classmethod 

330 def defaults(cls, config): 

331 """ """ 

332 

333 # nb. Upgrade may come from custom model 

334 wutta_config = config.registry.settings['wutta_config'] 

335 app = wutta_config.get_app() 

336 cls.model_class = app.model.Upgrade 

337 

338 cls._defaults(config) 

339 cls._upgrade_defaults(config) 

340 

341 @classmethod 

342 def _upgrade_defaults(cls, config): 

343 route_prefix = cls.get_route_prefix() 

344 permission_prefix = cls.get_permission_prefix() 

345 instance_url_prefix = cls.get_instance_url_prefix() 

346 

347 # execution progress 

348 config.add_route(f'{route_prefix}.execute_progress', 

349 f'{instance_url_prefix}/execute/progress') 

350 config.add_view(cls, attr='execute_progress', 

351 route_name=f'{route_prefix}.execute_progress', 

352 permission=f'{permission_prefix}.execute', 

353 renderer='json') 

354 

355 

356def defaults(config, **kwargs): 

357 base = globals() 

358 

359 UpgradeView = kwargs.get('UpgradeView', base['UpgradeView']) 

360 UpgradeView.defaults(config) 

361 

362 

363def includeme(config): 

364 defaults(config)