# Copyright (C) 2022 Cetmix OÜ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). import logging from ansi2html import Ansi2HTMLConverter from odoo import _, api, fields, models from .constants import COMMAND_STOPPED, GENERAL_ERROR html_converter = Ansi2HTMLConverter(inline=True) _logger = logging.getLogger(__name__) class CxTowerCommandLog(models.Model): """Command execution log""" _name = "cx.tower.command.log" _description = "Cetmix Tower Command Log" _order = "start_date desc, id desc" active = fields.Boolean(default=True) name = fields.Char(compute="_compute_name", store=True) label = fields.Char( help="Custom label. Can be used for search/tracking", index="trigram", unaccent=False, ) server_id = fields.Many2one( comodel_name="cx.tower.server", required=True, index=True, ondelete="cascade" ) # -- Time start_date = fields.Datetime(string="Started") finish_date = fields.Datetime(string="Finished") duration = fields.Float( help="Time consumed for execution, seconds", compute="_compute_duration", store=True, ) duration_current = fields.Float( string="Duration, sec", compute="_compute_duration_current", compute_sudo=True, help="For how long a flight plan is already running", ) # -- Command is_running = fields.Boolean( help="Command is being executed right now", compute="_compute_duration", store=True, ) command_id = fields.Many2one( comodel_name="cx.tower.command", required=True, index=True, ondelete="restrict" ) access_level = fields.Selection( related="command_id.access_level", readonly=True, store=True, index=True, ) command_action = fields.Selection(related="command_id.action", store=True) path = fields.Char(string="Execution Path", help="Where command was executed") code = fields.Text(string="Command Code", help="Command code that was executed") command_status = fields.Integer( string="Exit Code", help="0 if command finished successfully.\n" "-100 general error,\n" "-101 not found,\n" "-201 another instance of this command is running,\n" "-202 no runner found for the command action,\n" "-203 Python code execution failed,\n" "-205 plan line condition check failed,\n" "-206 command timed out,\n" "-207 command is not compatible with server,\n" "-208 command is stopped by user,\n" "503 if SSH connection error occurred", ) command_response = fields.Text(string="Response") command_error = fields.Text(string="Error") command_result_html = fields.Html( compute="_compute_command_result_html", help="Result converted to HTML. Used for SSH commands.", ) use_sudo = fields.Selection( string="Use sudo", selection=[("n", "Without password"), ("p", "With password")], help="Run commands using 'sudo'", ) condition = fields.Char( readonly=True, ) is_skipped = fields.Boolean( readonly=True, ) # -- Flight Plan plan_log_id = fields.Many2one(comodel_name="cx.tower.plan.log", ondelete="cascade") triggered_plan_log_id = fields.Many2one(comodel_name="cx.tower.plan.log") triggered_plan_command_log_ids = fields.One2many( comodel_name="cx.tower.command.log", inverse_name="plan_log_id", related="triggered_plan_log_id.command_log_ids", readonly=True, string="Triggered Flight Plan Commands", ) scheduled_task_id = fields.Many2one( "cx.tower.scheduled.task", ondelete="set null", help="Scheduled task that triggered this command", ) variable_values = fields.Json( default={}, help="Custom variable values passed to the command", ) @api.depends("name", "command_id.name") def _compute_name(self): for rec in self: rec.name = ": ".join((rec.server_id.name, rec.command_id.name)) # type: ignore @api.depends("start_date", "finish_date") def _compute_duration(self): for command_log in self: if not command_log.start_date: command_log.is_running = False continue if not command_log.finish_date: command_log.is_running = True continue duration = ( command_log.finish_date - command_log.start_date ).total_seconds() command_log.update( { "duration": duration, "is_running": False, } ) @api.depends("is_running") def _compute_duration_current(self): """Shows relative time between now() and start time for running commands, and computed duration for finished ones. """ now = fields.Datetime.now() for command_log in self: if command_log.is_running: command_log.duration_current = ( now - command_log.start_date ).total_seconds() else: command_log.duration_current = command_log.duration @api.depends("command_response", "command_error") def _compute_command_result_html(self): for command_log in self: command_result = command_log.command_response or command_log.command_error if command_result: try: command_log.command_result_html = html_converter.convert( command_result ) except Exception as e: _logger.error("Error converting command response to HTML: %s", e) command_log.command_result_html = _( "

Error converting command" " response to HTML: %(error)s

", error=e, ) else: command_log.command_result_html = False def start(self, server_id, command_id, start_date=None, **kwargs): """Creates initial log record when command is started Args: server_id (int) id of the server. command_id (int) id of the command. start_date (datetime) command start date time. **kwargs (dict): optional values Returns: (cx.tower.command.log()) new command log record or False """ vals = { "server_id": server_id, "command_id": command_id, "start_date": start_date if start_date else fields.Datetime.now(), } # Apply kwargs vals.update(kwargs) log_record = self.sudo().create(vals) return log_record def stop(self): """ Stop the command execution. """ user_name = self.env.user.name for log in self: if not log.is_running: continue log.finish( status=COMMAND_STOPPED, error=_("Stopped by user %(user)s", user=user_name), ) # Ensure flight plan log is stopped too if log.plan_log_id and log.plan_log_id.is_running: log.plan_log_id.stop() def finish( self, finish_date=None, status=None, response=None, error=None, **kwargs ): """Save final command result when command is finished. This method can be called for multiple command logs at once. Args: finish_date (datetime) command finish date time. status (int, optional): command execution status. Defaults to None. response (Char, optional): Command response. Defaults to None. error (Char, optional): Command error. Defaults to None. **kwargs (dict): optional values """ self_with_sudo = self.sudo() # Duration now = fields.Datetime.now() date_finish = finish_date if finish_date else now vals = { "finish_date": date_finish, "command_status": GENERAL_ERROR if status is None else status, "command_response": response, "command_error": error, } # Apply kwargs and write vals.update(kwargs) self_with_sudo.write(vals) # Trigger post finish hook for command_log in self_with_sudo: command_log._command_finished() def record( self, server_id, command_id, start_date=None, finish_date=None, status=0, response=None, error=None, **kwargs, ): """Record completed command directly without using start/stop Args: server_id (int) id of the server. command_id (int) id of the command. start_date (datetime) command start date time. finish_date (datetime) command finish date time. status (int, optional): command execution status. Defaults to 0. response (list, optional): SSH response. Defaults to None. error (list, optional): SSH error. Defaults to None. **kwargs (dict): values to store Returns: (cx.tower.command.log()) new command log record """ vals = kwargs or {} now = fields.Datetime.now() vals.update( { "server_id": server_id, "command_id": command_id, "start_date": start_date or now, "finish_date": finish_date or now, "command_status": status, "command_response": response, "command_error": error, } ) rec = self.sudo().create(vals) rec._command_finished() return rec def _command_finished(self): """Triggered when command is finished Inherit to implement your own hooks Returns: bool: True if event was handled """ self.ensure_one() # Do not notify if command is run from a Flight Plan. if self.plan_log_id: # type: ignore self.plan_log_id._plan_command_finished(self) # type: ignore return True # Check if notifications are enabled ICP_sudo = self.env["ir.config_parameter"].sudo() notification_type_success = ICP_sudo.get_param( "cetmix_tower_server.notification_type_success" ) notification_type_error = ICP_sudo.get_param( "cetmix_tower_server.notification_type_error" ) # Prepare notifications if not notification_type_success and not notification_type_error: return True # Use context timestamp to avoid timezone issues context_timestamp = fields.Datetime.context_timestamp( self, fields.Datetime.now() ) # Action for button action = self.env["ir.actions.act_window"]._for_xml_id( "cetmix_tower_server.action_cx_tower_command_log" ) context = self.env.context.copy() params = dict(context.get("params") or {}) params["button_name"] = _("View Log") context["params"] = params action.update( { "views": [(False, "form")], "context": context, "res_id": self.id, } ) # Send notification if self.command_status == 0 and notification_type_success: # Success notification self.create_uid.notify_success( message=_( "%(timestamp)s
" "Command '%(name)s' finished successfully", name=self.command_id.name, timestamp=context_timestamp, ), title=self.server_id.name, sticky=notification_type_success == "sticky", action=action, ) # Error notification if self.command_status != 0 and notification_type_error: self.create_uid.notify_danger( message=_( "%(timestamp)s
" "Command '%(name)s' finished with error", name=self.command_id.name, timestamp=context_timestamp, ), title=self.server_id.name, sticky=notification_type_error == "sticky", action=action, ) return True