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
« 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"""
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 import widgets
38from wuttaweb.forms.schema import UserRef, WuttaEnum, FileDownload
39from wuttaweb.progress import get_progress_session
42log = logging.getLogger(__name__)
45class UpgradeView(MasterView):
46 """
47 Master view for upgrades.
49 Default route prefix is ``upgrades``.
51 Notable URLs provided by this class:
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
65 grid_columns = [
66 'created',
67 'description',
68 'status',
69 'executed',
70 'executed_by',
71 ]
73 sort_defaults = ('created', 'desc')
75 def configure_grid(self, g):
76 """ """
77 super().configure_grid(g)
78 model = self.app.model
79 enum = self.app.enum
81 # description
82 g.set_link('description')
84 # created
85 g.set_renderer('created', self.grid_render_datetime)
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")
95 # status
96 g.set_renderer('status', self.grid_render_enum, enum=enum.UpgradeStatus)
98 # executed
99 g.set_renderer('executed', self.grid_render_datetime)
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")
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'
117 def configure_form(self, f):
118 """ """
119 super().configure_form(f)
120 enum = self.app.enum
121 upgrade = f.model_instance
123 # never show these
124 f.remove('created_by_uuid',
125 'executing',
126 'executed_by_uuid')
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 ])
139 # created
140 if self.creating or self.editing:
141 f.remove('created')
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))
149 # notes
150 f.set_widget('notes', widgets.NotesWidget())
152 # status
153 if self.creating:
154 f.remove('status')
155 else:
156 f.set_node('status', WuttaEnum(self.request, enum.UpgradeStatus))
158 # executed
159 if self.creating or self.editing or not upgrade.executed:
160 f.remove('executed')
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))
168 # exit_code
169 if self.creating or self.editing or not upgrade.executed:
170 f.remove('exit_code')
172 # stdout / stderr
173 if not (self.creating or self.editing) and upgrade.status in (
174 enum.UpgradeStatus.SUCCESS, enum.UpgradeStatus.FAILURE):
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'))
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'))
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)
199 super().delete_instance(upgrade)
201 def objectify(self, form):
202 """ """
203 upgrade = super().objectify(form)
204 enum = self.app.enum
206 # set user, status when creating
207 if self.creating:
208 upgrade.created_by = self.request.user
209 upgrade.status = enum.UpgradeStatus.PENDING
211 return upgrade
213 def download_path(self, upgrade, filename):
214 """ """
215 if filename:
216 return self.get_upgrade_filepath(upgrade, filename)
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
227 def execute_instance(self, upgrade, user, progress=None):
228 """
229 This method runs the actual upgrade.
231 Default logic will get the script command from config, and run
232 it via shell in a subprocess.
234 The ``stdout`` and ``stderr`` streams are captured to separate
235 log files which are then available to download.
237 The upgrade itself is marked as "executed" with status of
238 either ``SUCCESS`` or ``FAILURE``.
239 """
240 enum = self.app.enum
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')
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
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)
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
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')
278 # session has 'complete' flag set when operation is over
279 if session.get('complete'):
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)
287 elif session.get('error'): # uh-oh
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')
294 # our return value will include all from progress session
295 data = dict(session)
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()
312 return data
314 def configure_get_simple_settings(self):
315 """ """
317 script = self.config.get(f'{self.app.appname}.upgrades.command')
318 if not script:
319 pass
321 return [
323 # basics
324 {'name': f'{self.app.appname}.upgrades.command',
325 'default': script},
327 ]
329 @classmethod
330 def defaults(cls, config):
331 """ """
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
338 cls._defaults(config)
339 cls._upgrade_defaults(config)
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()
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')
356def defaults(config, **kwargs):
357 base = globals()
359 UpgradeView = kwargs.get('UpgradeView', base['UpgradeView'])
360 UpgradeView.defaults(config)
363def includeme(config):
364 defaults(config)