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

164 statements  

« prev     ^ index     » next       coverage.py v7.6.10, created at 2024-12-28 21:19 -0600

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.schema import UserRef, WuttaEnum, FileDownload 

38from wuttaweb.progress import get_progress_session 

39 

40 

41log = logging.getLogger(__name__) 

42 

43 

44class UpgradeView(MasterView): 

45 """ 

46 Master view for upgrades. 

47 

48 Default route prefix is ``upgrades``. 

49 

50 Notable URLs provided by this class: 

51 

52 * ``/upgrades/`` 

53 * ``/upgrades/new`` 

54 * ``/upgrades/XXX`` 

55 * ``/upgrades/XXX/edit`` 

56 * ``/upgrades/XXX/delete`` 

57 """ 

58 model_class = Upgrade 

59 executable = True 

60 execute_progress_template = '/upgrade.mako' 

61 downloadable = True 

62 configurable = True 

63 

64 grid_columns = [ 

65 'created', 

66 'description', 

67 'status', 

68 'executed', 

69 'executed_by', 

70 ] 

71 

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

73 

74 def configure_grid(self, g): 

75 """ """ 

76 super().configure_grid(g) 

77 model = self.app.model 

78 enum = self.app.enum 

79 

80 # description 

81 g.set_link('description') 

82 

83 # created 

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

85 

86 # created_by 

87 g.set_link('created_by') 

88 Creator = orm.aliased(model.User) 

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

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

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

92 label="Created By Username") 

93 

94 # status 

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

96 

97 # executed 

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

99 

100 # executed_by 

101 g.set_link('executed_by') 

102 Executor = orm.aliased(model.User) 

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

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

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

106 label="Executed By Username") 

107 

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

109 """ """ 

110 enum = self.app.enum 

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

112 return 'has-background-warning' 

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

114 return 'has-background-warning' 

115 

116 def configure_form(self, f): 

117 """ """ 

118 super().configure_form(f) 

119 enum = self.app.enum 

120 upgrade = f.model_instance 

121 

122 # never show these 

123 f.remove('created_by_uuid', 

124 'executing', 

125 'executed_by_uuid') 

126 

127 # sequence sanity 

128 f.fields.set_sequence([ 

129 'description', 

130 'notes', 

131 'status', 

132 'created', 

133 'created_by', 

134 'executed', 

135 'executed_by', 

136 ]) 

137 

138 # created 

139 if self.creating or self.editing: 

140 f.remove('created') 

141 

142 # created_by 

143 if self.creating or self.editing: 

144 f.remove('created_by') 

145 else: 

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

147 

148 # notes 

149 f.set_widget('notes', 'notes') 

150 

151 # status 

152 if self.creating: 

153 f.remove('status') 

154 else: 

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

156 

157 # executed 

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

159 f.remove('executed') 

160 

161 # executed_by 

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

163 f.remove('executed_by') 

164 else: 

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

166 

167 # exit_code 

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

169 f.remove('exit_code') 

170 

171 # stdout / stderr 

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

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

174 

175 # stdout_file 

176 f.append('stdout_file') 

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

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

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

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

181 

182 # stderr_file 

183 f.append('stderr_file') 

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

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

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

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

188 

189 def delete_instance(self, upgrade): 

190 """ 

191 We override this method to delete any files associated with 

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

193 """ 

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

195 if os.path.exists(path): 

196 shutil.rmtree(path) 

197 

198 super().delete_instance(upgrade) 

199 

200 def objectify(self, form): 

201 """ """ 

202 upgrade = super().objectify(form) 

203 enum = self.app.enum 

204 

205 # set user, status when creating 

206 if self.creating: 

207 upgrade.created_by = self.request.user 

208 upgrade.status = enum.UpgradeStatus.PENDING 

209 

210 return upgrade 

211 

212 def download_path(self, upgrade, filename): 

213 """ """ 

214 if filename: 

215 return self.get_upgrade_filepath(upgrade, filename) 

216 

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

218 """ """ 

219 uuid = str(upgrade.uuid) 

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

221 create=create) 

222 if filename: 

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

224 return path 

225 

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

227 """ 

228 This method runs the actual upgrade. 

229 

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

231 it via shell in a subprocess. 

232 

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

234 log files which are then available to download. 

235 

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

237 either ``SUCCESS`` or ``FAILURE``. 

238 """ 

239 enum = self.app.enum 

240 

241 # locate file paths 

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

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

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

245 

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

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

248 # but also update local object to reflect the change 

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

250 alt = s.merge(upgrade) 

251 alt.status = enum.UpgradeStatus.EXECUTING 

252 upgrade.status = enum.UpgradeStatus.EXECUTING 

253 

254 # run the command 

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

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

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

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

259 stdout=stdout, stderr=stderr) 

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

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

262 

263 # declare it complete 

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

265 upgrade.executed_by = user 

266 if upgrade.exit_code == 0: 

267 upgrade.status = enum.UpgradeStatus.SUCCESS 

268 else: 

269 upgrade.status = enum.UpgradeStatus.FAILURE 

270 

271 def execute_progress(self): 

272 """ """ 

273 route_prefix = self.get_route_prefix() 

274 upgrade = self.get_instance() 

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

276 

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

278 if session.get('complete'): 

279 

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

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

282 msg = session.get('success_msg') 

283 if msg: 

284 self.request.session.flash(msg) 

285 

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

287 

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

289 # since user is about to get redirected. 

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

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

292 

293 # our return value will include all from progress session 

294 data = dict(session) 

295 

296 # add whatever might be new from upgrade process STDOUT 

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

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

299 if os.path.exists(path): 

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

301 if size > 0: 

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

303 with open(path) as f: 

304 f.seek(offset) 

305 chunk = f.read(size) 

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

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

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

309 session.save() 

310 

311 return data 

312 

313 def configure_get_simple_settings(self): 

314 """ """ 

315 

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

317 if not script: 

318 pass 

319 

320 return [ 

321 

322 # basics 

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

324 'default': script}, 

325 

326 ] 

327 

328 @classmethod 

329 def defaults(cls, config): 

330 """ """ 

331 

332 # nb. Upgrade may come from custom model 

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

334 app = wutta_config.get_app() 

335 cls.model_class = app.model.Upgrade 

336 

337 cls._defaults(config) 

338 cls._upgrade_defaults(config) 

339 

340 @classmethod 

341 def _upgrade_defaults(cls, config): 

342 route_prefix = cls.get_route_prefix() 

343 permission_prefix = cls.get_permission_prefix() 

344 instance_url_prefix = cls.get_instance_url_prefix() 

345 

346 # execution progress 

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

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

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

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

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

352 renderer='json') 

353 

354 

355def defaults(config, **kwargs): 

356 base = globals() 

357 

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

359 UpgradeView.defaults(config) 

360 

361 

362def includeme(config): 

363 defaults(config)