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
« 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"""
27import datetime
28import logging
29import os
30import shutil
31import subprocess
33from sqlalchemy import orm
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
41log = logging.getLogger(__name__)
44class UpgradeView(MasterView):
45 """
46 Master view for upgrades.
48 Default route prefix is ``upgrades``.
50 Notable URLs provided by this class:
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
64 grid_columns = [
65 'created',
66 'description',
67 'status',
68 'executed',
69 'executed_by',
70 ]
72 sort_defaults = ('created', 'desc')
74 def configure_grid(self, g):
75 """ """
76 super().configure_grid(g)
77 model = self.app.model
78 enum = self.app.enum
80 # description
81 g.set_link('description')
83 # created
84 g.set_renderer('created', self.grid_render_datetime)
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")
94 # status
95 g.set_renderer('status', self.grid_render_enum, enum=enum.UpgradeStatus)
97 # executed
98 g.set_renderer('executed', self.grid_render_datetime)
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")
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'
116 def configure_form(self, f):
117 """ """
118 super().configure_form(f)
119 enum = self.app.enum
120 upgrade = f.model_instance
122 # never show these
123 f.remove('created_by_uuid',
124 'executing',
125 'executed_by_uuid')
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 ])
138 # created
139 if self.creating or self.editing:
140 f.remove('created')
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))
148 # notes
149 f.set_widget('notes', 'notes')
151 # status
152 if self.creating:
153 f.remove('status')
154 else:
155 f.set_node('status', WuttaEnum(self.request, enum.UpgradeStatus))
157 # executed
158 if self.creating or self.editing or not upgrade.executed:
159 f.remove('executed')
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))
167 # exit_code
168 if self.creating or self.editing or not upgrade.executed:
169 f.remove('exit_code')
171 # stdout / stderr
172 if not (self.creating or self.editing) and upgrade.status in (
173 enum.UpgradeStatus.SUCCESS, enum.UpgradeStatus.FAILURE):
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'))
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'))
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)
198 super().delete_instance(upgrade)
200 def objectify(self, form):
201 """ """
202 upgrade = super().objectify(form)
203 enum = self.app.enum
205 # set user, status when creating
206 if self.creating:
207 upgrade.created_by = self.request.user
208 upgrade.status = enum.UpgradeStatus.PENDING
210 return upgrade
212 def download_path(self, upgrade, filename):
213 """ """
214 if filename:
215 return self.get_upgrade_filepath(upgrade, filename)
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
226 def execute_instance(self, upgrade, user, progress=None):
227 """
228 This method runs the actual upgrade.
230 Default logic will get the script command from config, and run
231 it via shell in a subprocess.
233 The ``stdout`` and ``stderr`` streams are captured to separate
234 log files which are then available to download.
236 The upgrade itself is marked as "executed" with status of
237 either ``SUCCESS`` or ``FAILURE``.
238 """
239 enum = self.app.enum
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')
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
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)
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
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')
277 # session has 'complete' flag set when operation is over
278 if session.get('complete'):
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)
286 elif session.get('error'): # uh-oh
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')
293 # our return value will include all from progress session
294 data = dict(session)
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()
311 return data
313 def configure_get_simple_settings(self):
314 """ """
316 script = self.config.get(f'{self.app.appname}.upgrades.command')
317 if not script:
318 pass
320 return [
322 # basics
323 {'name': f'{self.app.appname}.upgrades.command',
324 'default': script},
326 ]
328 @classmethod
329 def defaults(cls, config):
330 """ """
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
337 cls._defaults(config)
338 cls._upgrade_defaults(config)
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()
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')
355def defaults(config, **kwargs):
356 base = globals()
358 UpgradeView = kwargs.get('UpgradeView', base['UpgradeView'])
359 UpgradeView.defaults(config)
362def includeme(config):
363 defaults(config)