654 lines
24 KiB
Python
654 lines
24 KiB
Python
# Copyright (C) 2024 Cetmix OÜ
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
|
|
|
from odoo import _, api, fields, models
|
|
from odoo.exceptions import ValidationError
|
|
|
|
|
|
class CxTowerServerTemplate(models.Model):
|
|
"""Server Template. Used to simplify server creation"""
|
|
|
|
_name = "cx.tower.server.template"
|
|
_inherit = [
|
|
"cx.tower.reference.mixin",
|
|
"mail.thread",
|
|
"mail.activity.mixin",
|
|
"cx.tower.access.role.mixin",
|
|
"cx.tower.tag.mixin",
|
|
]
|
|
_description = "Cetmix Tower Server Template"
|
|
_order = "name"
|
|
|
|
active = fields.Boolean(default=True)
|
|
|
|
# --- Connection
|
|
ssh_port = fields.Integer(string="SSH port", default=22)
|
|
ssh_username = fields.Char(string="SSH Username")
|
|
ssh_password = fields.Char(string="SSH Password")
|
|
ssh_key_id = fields.Many2one(
|
|
comodel_name="cx.tower.key",
|
|
string="SSH Private Key",
|
|
domain=[("key_type", "=", "k")],
|
|
)
|
|
ssh_auth_mode = fields.Selection(
|
|
string="SSH Auth Mode",
|
|
selection=[
|
|
("p", "Password"),
|
|
("k", "Key"),
|
|
],
|
|
)
|
|
use_sudo = fields.Selection(
|
|
string="Use sudo",
|
|
selection=[("n", "Without password"), ("p", "With password")],
|
|
help="Run commands using 'sudo'",
|
|
)
|
|
|
|
# --- Attributes
|
|
color = fields.Integer(help="For better visualization in views")
|
|
os_id = fields.Many2one(string="Operating System", comodel_name="cx.tower.os")
|
|
tag_ids = fields.Many2many(
|
|
relation="cx_tower_server_template_tag_rel",
|
|
column1="server_template_id",
|
|
column2="tag_id",
|
|
)
|
|
|
|
# --- Variables
|
|
# We are not using variable mixin because we don't need to parse values
|
|
variable_value_ids = fields.One2many(
|
|
string="Variable Values",
|
|
comodel_name="cx.tower.variable.value",
|
|
auto_join=True,
|
|
inverse_name="server_template_id",
|
|
)
|
|
|
|
# --- Server logs
|
|
server_log_ids = fields.One2many(
|
|
comodel_name="cx.tower.server.log", inverse_name="server_template_id"
|
|
)
|
|
|
|
# --- Shortcuts
|
|
shortcut_ids = fields.Many2many(
|
|
comodel_name="cx.tower.shortcut",
|
|
relation="cx_tower_server_template_shortcut_rel",
|
|
column1="server_template_id",
|
|
column2="shortcut_id",
|
|
string="Shortcuts",
|
|
)
|
|
|
|
# --- Scheduled Tasks
|
|
scheduled_task_ids = fields.Many2many(
|
|
comodel_name="cx.tower.scheduled.task",
|
|
relation="cx_tower_server_template_scheduled_task_rel",
|
|
column1="server_template_id",
|
|
column2="scheduled_task_id",
|
|
string="Scheduled Tasks",
|
|
)
|
|
|
|
# --- Flight Plan
|
|
flight_plan_id = fields.Many2one(
|
|
"cx.tower.plan",
|
|
help="This flight plan will be run upon server creation",
|
|
domain="[('server_ids', '=', False)]",
|
|
)
|
|
|
|
# ---- Delete plan
|
|
plan_delete_id = fields.Many2one(
|
|
"cx.tower.plan",
|
|
string="On Delete Plan",
|
|
groups="cetmix_tower_server.group_manager",
|
|
help="This Flightplan will be executed when the server is deleted",
|
|
)
|
|
|
|
# --- Created Servers
|
|
server_ids = fields.One2many(
|
|
comodel_name="cx.tower.server",
|
|
inverse_name="server_template_id",
|
|
)
|
|
server_count = fields.Integer(
|
|
compute="_compute_server_count",
|
|
)
|
|
|
|
# -- Other
|
|
note = fields.Text()
|
|
|
|
# ---- Access. Add relation for mixin fields
|
|
user_ids = fields.Many2many(
|
|
relation="cx_tower_server_template_user_rel",
|
|
domain=lambda self: [
|
|
("groups_id", "in", [self.env.ref("cetmix_tower_server.group_manager").id])
|
|
],
|
|
)
|
|
manager_ids = fields.Many2many(
|
|
relation="cx_tower_server_template_manager_rel",
|
|
)
|
|
|
|
@api.depends("server_ids")
|
|
def _compute_server_count(self):
|
|
"""
|
|
Compute total server counts created from the templates
|
|
"""
|
|
for template in self:
|
|
template.server_count = len(template.server_ids)
|
|
|
|
def copy(self, default=None):
|
|
"""Duplicate the server template along with variable values and server logs."""
|
|
default = dict(default or {})
|
|
|
|
# Duplicate the server template itself
|
|
new_template = super().copy(default)
|
|
|
|
# Duplicate variable values
|
|
for variable_value in self.variable_value_ids:
|
|
variable_value.with_context(reference_mixin_skip_self=True).copy(
|
|
{"server_template_id": new_template.id}
|
|
)
|
|
|
|
# Duplicate server logs
|
|
for server_log in self.server_log_ids:
|
|
server_log.copy({"server_template_id": new_template.id})
|
|
|
|
return new_template
|
|
|
|
def action_create_server(self):
|
|
"""
|
|
Returns wizard action to create new server
|
|
"""
|
|
self.ensure_one()
|
|
context = self.env.context.copy()
|
|
context.update(
|
|
{
|
|
"default_server_template_id": self.id, # pylint: disable=no-member
|
|
"default_color": self.color,
|
|
"default_ssh_port": self.ssh_port,
|
|
"default_ssh_username": self.ssh_username,
|
|
"default_ssh_password": self.ssh_password,
|
|
"default_ssh_key_id": self.ssh_key_id.id,
|
|
"default_ssh_auth_mode": self.ssh_auth_mode,
|
|
"default_plan_delete_id": self.plan_delete_id.id,
|
|
}
|
|
)
|
|
if self.variable_value_ids:
|
|
context.update(
|
|
{
|
|
"default_line_ids": [
|
|
(
|
|
0,
|
|
0,
|
|
{
|
|
"variable_value_id": line.id,
|
|
},
|
|
)
|
|
for line in self.variable_value_ids
|
|
]
|
|
}
|
|
)
|
|
return {
|
|
"type": "ir.actions.act_window",
|
|
"name": _("Create Server"),
|
|
"res_model": "cx.tower.server.template.create.wizard",
|
|
"view_mode": "form",
|
|
"target": "new",
|
|
"context": context,
|
|
}
|
|
|
|
def action_open_servers(self):
|
|
"""
|
|
Return action to open related servers
|
|
"""
|
|
self.ensure_one()
|
|
action = self.env["ir.actions.act_window"]._for_xml_id(
|
|
"cetmix_tower_server.action_cx_tower_server"
|
|
)
|
|
action.update(
|
|
{
|
|
"domain": [("server_template_id", "=", self.id)], # pylint: disable=no-member
|
|
}
|
|
)
|
|
return action
|
|
|
|
@api.model
|
|
def create_server_from_template(self, template_reference, server_name, **kwargs):
|
|
"""This is a wrapper function that is meant to be called
|
|
when we need to create a server from specific server template
|
|
|
|
Args:
|
|
template_reference (Char): Server template reference
|
|
server_name (Char): Name of the new server
|
|
|
|
Kwargs:
|
|
partner (res.partner(), optional): Partner this server belongs to.
|
|
ipv4 (Char, optional): IP v4 address. Defaults to None.
|
|
ipv6 (Char, optional): IP v6 address.
|
|
Must be provided in case IP v4 is not. Defaults to None.
|
|
ssh_password (Char, optional): SSH password. Defaults to None.
|
|
ssh_key (Char, optional): SSH private key record reference.
|
|
Defaults to None.
|
|
configuration_variables (Dict, optional): Custom configuration variable.
|
|
Following format is used:
|
|
`variable_reference`: `variable_value_char`
|
|
eg:
|
|
{'branch': 'prod', 'odoo_version': '16.0'}
|
|
pick_all_template_variables (bool): This parameter ensures that the server
|
|
being created considers existing variables from the template.
|
|
If enabled, the template variables will also be included in the server
|
|
variables. The default value is True.
|
|
|
|
Returns:
|
|
cx.tower.server: newly created server record
|
|
"""
|
|
template = self.get_by_reference(template_reference)
|
|
return template._create_new_server(server_name, **kwargs)
|
|
|
|
def _create_new_server(self, name, **kwargs):
|
|
"""Creates a new server from template
|
|
|
|
Args:
|
|
name (Char): Name of the new server
|
|
|
|
Kwargs:
|
|
partner (res.partner(), optional): Partner this server belongs to.
|
|
ipv4 (Char, optional): IP v4 address. Defaults to None.
|
|
ipv6 (Char, optional): IP v6 address.
|
|
Must be provided in case IP v4 is not. Defaults to None.
|
|
ssh_password (Char, optional): SSH password. Defaults to None.
|
|
ssh_key (Char, optional): SSH private key record reference.
|
|
Defaults to None.
|
|
configuration_variables (Dict, optional): Custom configuration variable.
|
|
Following format is used:
|
|
`variable_reference`: `variable_value_char`
|
|
eg:
|
|
{'branch': 'prod', 'odoo_version': '16.0'}
|
|
pick_all_template_variables (bool): This parameter ensures that the server
|
|
being created considers existing variables from the template.
|
|
If enabled, the template variables will also be included in the server
|
|
variables. The default value is True.
|
|
|
|
Returns:
|
|
cx.tower.server: newly created server record
|
|
"""
|
|
self.ensure_one()
|
|
|
|
# Retrieve the passed variables
|
|
configuration_variables = kwargs.get("configuration_variables", {})
|
|
|
|
# We validate mandatory variables
|
|
if not kwargs.get("pick_all_template_variables"):
|
|
self._validate_required_variables(configuration_variables)
|
|
|
|
# We are using sudo to ensure all values are copied
|
|
server_values = self.sudo()._prepare_server_values(
|
|
name=name,
|
|
server_template_id=self.id, # pylint: disable=no-member
|
|
**kwargs,
|
|
)
|
|
|
|
# Pop variable values to add them after server creation.
|
|
# This is needed to ensure that access rules are applied properly.
|
|
variable_values = server_values.pop("variable_value_ids")
|
|
|
|
# Prepare context for server creation
|
|
context = self.env.context.copy()
|
|
|
|
# SSH setting may be added after server creation.
|
|
context.update({"skip_ssh_settings_check": True})
|
|
# We need to remove default_server_template_id to avoid it being used
|
|
# in variable values.
|
|
context.pop("default_server_template_id", None)
|
|
|
|
# Create server
|
|
server = (
|
|
self.env["cx.tower.server"] # pylint: disable=context-overridden # new need a new clean context
|
|
.sudo()
|
|
.with_context(context)
|
|
.create(server_values)
|
|
.sudo()
|
|
)
|
|
|
|
# Add variable values
|
|
if variable_values:
|
|
server.with_context(context).write({"variable_value_ids": variable_values}) # pylint: disable=context-overridden # new need a new clean context
|
|
|
|
# Create server logs
|
|
logs = server.server_log_ids.filtered(lambda rec: rec.log_type == "file")
|
|
for log in logs.sudo():
|
|
log.file_id = log.file_template_id.create_file(
|
|
server=server, if_file_exists="skip"
|
|
).id
|
|
|
|
flight_plan = server.server_template_id.flight_plan_id
|
|
if flight_plan:
|
|
server.run_flight_plan(flight_plan)
|
|
|
|
return server
|
|
|
|
def _get_post_create_fields(self):
|
|
"""
|
|
Add fields that should be populated after server template creation
|
|
"""
|
|
res = super()._get_post_create_fields()
|
|
return res + ["variable_value_ids", "server_log_ids"]
|
|
|
|
def _get_fields_tower_server(self):
|
|
"""
|
|
Return field name list to read from template and create new server
|
|
"""
|
|
return [
|
|
"ssh_username",
|
|
"ssh_password",
|
|
"ssh_key_id",
|
|
"ssh_auth_mode",
|
|
"use_sudo",
|
|
"color",
|
|
"os_id",
|
|
"plan_delete_id",
|
|
"tag_ids",
|
|
"variable_value_ids",
|
|
"server_log_ids",
|
|
"shortcut_ids",
|
|
"scheduled_task_ids",
|
|
]
|
|
|
|
def _prepare_server_values(self, pick_all_template_variables=True, **kwargs):
|
|
"""
|
|
Prepare the server values to create a new server based on
|
|
the current template. It reads all fields from the template, copies them,
|
|
and processes One2many fields to create new related records. Magic fields
|
|
like 'id', concurrency fields, and audit fields are excluded from the copied
|
|
data.
|
|
|
|
Args:
|
|
pick_all_template_variables (bool): This parameter ensures that the server
|
|
being created considers existing variables from the template.
|
|
If enabled, the template variables will also be included in the server
|
|
variables. The default value is True.
|
|
**kwargs: Additional values to update in the final server record.
|
|
|
|
Returns:
|
|
list: A list of dictionaries representing the values for the new server
|
|
records.
|
|
"""
|
|
model_fields = self._fields
|
|
field_o2m_type = fields.One2many
|
|
|
|
# define the magic fields that should not be copied
|
|
# (including ID and concurrency fields)
|
|
MAGIC_FIELDS = models.MAGIC_COLUMNS + [self.CONCURRENCY_CHECK_FIELD]
|
|
|
|
# read all values required to create a new server from the template
|
|
values = self.read(self._get_fields_tower_server(), load=False)[0]
|
|
|
|
# prepare server config values from kwargs
|
|
server_config_values = self._parse_server_config_values(kwargs)
|
|
template = self.browse(values["id"])
|
|
|
|
# Process each field in the template
|
|
for field in values.keys():
|
|
if isinstance(model_fields[field], field_o2m_type):
|
|
# get related records for One2many field
|
|
related_records = getattr(template, field)
|
|
new_records = []
|
|
# for each related record, read its data and prepare it for copying
|
|
for record in related_records:
|
|
record_data = {
|
|
k: v
|
|
for k, v in record.read(load=False)[0].items()
|
|
if k not in MAGIC_FIELDS
|
|
}
|
|
# set the inverse field (link back to the template)
|
|
# to False to unlink from the original template
|
|
record_data[model_fields[field].inverse_name] = False
|
|
new_records.append((0, 0, record_data))
|
|
|
|
values[field] = new_records
|
|
|
|
# Handle configuration variables if provided.
|
|
configuration_variables = kwargs.pop("configuration_variables", None)
|
|
configuration_variable_options = kwargs.pop(
|
|
"configuration_variable_options", {}
|
|
)
|
|
|
|
if configuration_variables:
|
|
# Validate required variables
|
|
self._validate_required_variables(configuration_variables)
|
|
|
|
# Search for existing variable options.
|
|
option_references = list(configuration_variable_options.values())
|
|
existing_options = option_references and self.env[
|
|
"cx.tower.variable.option"
|
|
].search([("reference", "in", option_references)])
|
|
missing_options = list(
|
|
set(option_references)
|
|
- {option.reference for option in existing_options}
|
|
)
|
|
|
|
if missing_options:
|
|
# Map variable references to their corresponding
|
|
# invalid option references.
|
|
missing_options_to_variables = {
|
|
var_ref: opt_ref
|
|
for var_ref, opt_ref in configuration_variable_options.items()
|
|
if opt_ref in missing_options
|
|
}
|
|
# Generate a detailed error message for invalid variable options.
|
|
detailed_message = "\n".join(
|
|
_(
|
|
"Variable reference '%(var_ref)s' has an invalid "
|
|
"option reference '%(opt_ref)s'.",
|
|
var_ref=var_ref,
|
|
opt_ref=opt_ref,
|
|
)
|
|
for var_ref, opt_ref in missing_options_to_variables.items()
|
|
)
|
|
raise ValidationError(
|
|
_(
|
|
"Some variable options are invalid:\n%(detailed_message)s",
|
|
detailed_message=detailed_message,
|
|
)
|
|
)
|
|
|
|
# Map variable options to their IDs.
|
|
configuration_variable_options_dict = {
|
|
option.variable_id.id: option for option in existing_options
|
|
}
|
|
|
|
variable_obj = self.env["cx.tower.variable"]
|
|
variable_references = list(configuration_variables.keys())
|
|
|
|
# Search for existing variables or create new ones if missing.
|
|
exist_variables = variable_obj.search(
|
|
[("reference", "in", variable_references)]
|
|
)
|
|
missing_references = list(
|
|
set(variable_references)
|
|
- {variable.reference for variable in exist_variables}
|
|
)
|
|
variable_vals_list = [
|
|
{"name": reference} for reference in missing_references
|
|
]
|
|
new_variables = variable_obj.create(variable_vals_list)
|
|
all_variables = exist_variables | new_variables
|
|
|
|
# Build a dictionary {variable: variable_value}.
|
|
configuration_variable_dict = {
|
|
variable: configuration_variables[variable.reference]
|
|
for variable in all_variables
|
|
}
|
|
|
|
server_variable_vals_list = []
|
|
for variable, variable_value in configuration_variable_dict.items():
|
|
variable_option = configuration_variable_options_dict.get(variable.id)
|
|
|
|
server_variable_vals_list.append(
|
|
(
|
|
0,
|
|
0,
|
|
{
|
|
"variable_id": variable.id,
|
|
"value_char": variable_option
|
|
and variable_option.value_char
|
|
or variable_value,
|
|
"option_id": variable_option and variable_option.id,
|
|
},
|
|
)
|
|
)
|
|
|
|
if pick_all_template_variables:
|
|
# update or add variable values
|
|
existing_variable_values = values.get("variable_value_ids", [])
|
|
variable_id_to_index = {
|
|
cmd[2]["variable_id"]: idx
|
|
for idx, cmd in enumerate(existing_variable_values)
|
|
if cmd[0] == 0 and "variable_id" in cmd[2]
|
|
}
|
|
|
|
# Update exist variable options
|
|
for exist_variable_id, index in variable_id_to_index.items():
|
|
option = configuration_variable_options_dict.get(exist_variable_id)
|
|
if not option:
|
|
continue
|
|
existing_variable_values[index][2].update(
|
|
{
|
|
"option_id": option.id,
|
|
"value_char": option.value_char,
|
|
}
|
|
)
|
|
|
|
# Prepare new command values for server variables
|
|
for new_command in server_variable_vals_list:
|
|
variable_id = new_command[2]["variable_id"]
|
|
if variable_id in variable_id_to_index:
|
|
idx = variable_id_to_index[variable_id]
|
|
# update exist command
|
|
existing_variable_values[idx] = new_command
|
|
else:
|
|
# add new command
|
|
existing_variable_values.append(new_command)
|
|
|
|
values["variable_value_ids"] = existing_variable_values
|
|
else:
|
|
values["variable_value_ids"] = server_variable_vals_list
|
|
|
|
# remove the `id` field to ensure a new record is created
|
|
# instead of updating the existing one
|
|
del values["id"]
|
|
# update the values with additional arguments from kwargs
|
|
values.update(kwargs)
|
|
# update server configs
|
|
values.update(server_config_values)
|
|
# Add current user as user/manager to the newly created server
|
|
values.update(
|
|
{
|
|
"user_ids": [(6, 0, self._default_user_ids())],
|
|
"manager_ids": [(6, 0, self._default_manager_ids())],
|
|
}
|
|
)
|
|
|
|
return values
|
|
|
|
def _parse_server_config_values(self, config_values):
|
|
"""
|
|
Prepares server configuration values.
|
|
|
|
Args:
|
|
config_values (dict): A dictionary containing server configuration values.
|
|
Keys and their expected values:
|
|
- partner (res.partner, optional): The partner this server
|
|
belongs to.
|
|
- ipv4 (str, optional): IPv4 address. Defaults to None.
|
|
- ipv6 (str, optional): IPv6 address. Must be provided if IPv4 is
|
|
not specified. Defaults to None.
|
|
- ssh_key (str, optional): Reference to an SSH private key record.
|
|
Defaults to None.
|
|
|
|
Returns:
|
|
dict: A dictionary containing parsed server configuration values with the
|
|
following keys:
|
|
- partner_id (int, optional): ID of the partner.
|
|
- ssh_key_id (int, optional): ID of the associated SSH key.
|
|
- ip_v4_address (str, optional): Parsed IPv4 address.
|
|
- ip_v6_address (str, optional): Parsed IPv6 address.
|
|
"""
|
|
values = {}
|
|
|
|
# This field is always populated from Server Template and
|
|
# cannot be altered with function params.
|
|
config_values.pop("plan_delete_id", None)
|
|
|
|
partner = config_values.pop("partner", None)
|
|
if partner:
|
|
values["partner_id"] = partner.id
|
|
|
|
ssh_key_reference = config_values.pop("ssh_key", None)
|
|
if ssh_key_reference:
|
|
ssh_key = self.env["cx.tower.key"].get_by_reference(ssh_key_reference)
|
|
if ssh_key:
|
|
values["ssh_key_id"] = ssh_key.id
|
|
|
|
ipv4 = config_values.pop("ipv4", None)
|
|
if ipv4:
|
|
values["ip_v4_address"] = ipv4
|
|
|
|
ipv6 = config_values.pop("ipv6", None)
|
|
if ipv6:
|
|
values["ip_v6_address"] = ipv6
|
|
|
|
return values
|
|
|
|
def _validate_required_variables(self, configuration_variables):
|
|
"""
|
|
Validate that all required variables are present, not empty,
|
|
and that no required variable is entirely missing from the configuration.
|
|
|
|
Args:
|
|
configuration_variables (dict): A dictionary of variable references
|
|
and their values.
|
|
|
|
Raises:
|
|
ValidationError: If all required variables are
|
|
missing from the configuration,
|
|
or if any required variable is empty or missing.
|
|
"""
|
|
required_variables = self.variable_value_ids.filtered("required")
|
|
if not required_variables:
|
|
return
|
|
|
|
required_refs = [var.variable_reference for var in required_variables]
|
|
config_refs = list(configuration_variables.keys())
|
|
|
|
missing_variables = [ref for ref in required_refs if ref not in config_refs]
|
|
empty_variables = [
|
|
ref
|
|
for ref in required_refs
|
|
if ref in config_refs and not configuration_variables[ref]
|
|
]
|
|
|
|
if not (missing_variables or empty_variables):
|
|
return
|
|
|
|
error_parts = [
|
|
_("Please resolve the following issues with configuration variables:")
|
|
]
|
|
|
|
if missing_variables:
|
|
error_parts.append(
|
|
_(
|
|
" - Missing variables: %(variables)s",
|
|
variables=", ".join(missing_variables),
|
|
)
|
|
)
|
|
|
|
if empty_variables:
|
|
error_parts.append(
|
|
_(
|
|
" - Empty values for variables: %(variables)s",
|
|
variables=", ".join(empty_variables),
|
|
)
|
|
)
|
|
|
|
raise ValidationError("\n".join(error_parts))
|
|
|
|
def _get_dependent_model_relation_fields(self):
|
|
"""Check cx.tower.reference.mixin for the function documentation"""
|
|
res = super()._get_dependent_model_relation_fields()
|
|
return res + ["variable_value_ids"]
|