Compare commits

...

2 commits

Author SHA1 Message Date
Marc Durepos
d0e7a216b4 rename k8s_odoo_manager 2025-04-04 15:11:15 -04:00
Marc Durepos
a66f6dbbfd initial commit k8s_odoo_manager 2025-04-04 15:06:47 -04:00
13 changed files with 2256 additions and 0 deletions

View file

@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
from . import models
from . import controllers

View file

@ -0,0 +1,68 @@
# -*- coding: utf-8 -*-
{
'name': 'Kubernetes Odoo Manager',
'version': '1.0.0',
'category': 'Administration',
'summary': 'Manage Odoo instances running on Kubernetes',
'author': 'Bemade Inc.',
'website': 'https://bemade.org',
'license': 'LGPL-3',
'depends': [
'base',
'web',
'mail',
],
'data': [
'security/k8s_odoo_manager_security.xml',
'security/ir.model.access.csv',
'views/k8s_cluster_views.xml',
'views/k8s_odoo_instance_views.xml',
'views/k8s_odoo_manager_menus.xml',
'data/k8s_odoo_manager_data.xml',
'wizards/wizard_views.xml',
],
'assets': {
'web.assets_backend': [
'bemade_k8s_odoo_manager/static/src/js/instance_dashboard.js',
'bemade_k8s_odoo_manager/static/src/scss/instance_dashboard.scss',
'bemade_k8s_odoo_manager/static/src/xml/instance_dashboard.xml',
],
},
'installable': True,
'application': True,
'auto_install': False,
'description': """
Kubernetes Odoo Manager
======================
This module provides an interface for managing Odoo instances running on Kubernetes clusters.
Features
--------
* **Kubernetes Connection**:
- Connect to a Kubernetes cluster using kubeconfig file authentication
- Securely store and manage kubeconfig credentials
* **Odoo Instance Management**:
- Create, update, and delete Odoo instances
- Scale instances up or down
- Configure resources (CPU, memory, storage)
- Manage domain names and TLS certificates
- Set up custom configuration options
* **Upgrade Management**:
- Schedule and execute module upgrades
- Track upgrade history
Technical Information
--------------------
This module interfaces with the Kubernetes API to manage Odoo instances using the Odoo Operator
custom resource definition (CRD). It provides a user-friendly interface for operations that would
otherwise require command-line tools or direct API access.
The module is designed to work with the Bemade Odoo Operator for Kubernetes, which handles the
actual deployment and management of Odoo instances on the cluster.
""",
}

View file

@ -0,0 +1,17 @@
CREATE OR REPLACE VIEW k8s_odoo_upgrade AS
SELECT
0 AS id,
NULL::integer AS instance_id,
NULL::integer AS cluster_id,
NULL::varchar AS job_name,
NULL::varchar AS display_name,
NULL::varchar AS state,
NULL::timestamp AS scheduled_date,
NULL::timestamp AS start_date,
NULL::timestamp AS end_date,
NULL::float AS duration,
NULL::varchar AS modules,
NULL::varchar AS database,
NULL::varchar AS status_message,
NULL::text AS log
WHERE 1=0; -- Empty view by default

View file

@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
from . import k8s_cluster
from . import k8s_odoo_instance

View file

@ -0,0 +1,311 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api, _
from odoo.exceptions import UserError, ValidationError
import base64
import logging
import tempfile
import os
import re
import kubernetes.client
import kubernetes.config
from kubernetes.client.rest import ApiException
_logger = logging.getLogger(__name__)
class K8sCluster(models.Model):
_name = 'k8s.cluster'
_description = 'Kubernetes Cluster'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'name'
name = fields.Char(
string='Name',
required=True,
tracking=True,
help='Name of the Kubernetes cluster'
)
description = fields.Text(
string='Description',
tracking=True,
help='Description of the cluster and its purpose'
)
active = fields.Boolean(
string='Active',
default=True,
tracking=True,
help='Whether this cluster configuration is active'
)
kubeconfig_attachment_id = fields.Many2one(
'ir.attachment',
string='Kubeconfig File',
required=True,
ondelete='restrict',
domain="[('res_model', '=', 'k8s.cluster'), ('res_field', '=', 'kubeconfig_data')]",
help='The kubeconfig file for connecting to the cluster'
)
kubeconfig_data = fields.Binary(
string='Kubeconfig Data',
attachment=True,
required=True,
help='The kubeconfig file content'
)
kubeconfig_filename = fields.Char(string='Kubeconfig Filename')
namespace = fields.Char(
string='Default Namespace',
default='default',
required=True,
help='The default namespace to use for operations'
)
default_tls_issuer = fields.Char(
string='Default TLS Issuer',
default='letsencrypt-prod',
required=True,
help='The default cert-manager issuer to use for TLS certificates'
)
connection_status = fields.Selection([
('not_tested', 'Not Tested'),
('connected', 'Connected'),
('failed', 'Connection Failed')
], string='Connection Status', default='not_tested', readonly=True)
connection_error = fields.Text(string='Connection Error', readonly=True)
last_connection_test = fields.Datetime(string='Last Connection Test', readonly=True)
cluster_version = fields.Char(string='Kubernetes Version', readonly=True)
node_count = fields.Integer(string='Node Count', readonly=True)
# Related records
instance_count = fields.Integer(
string='Instance Count',
compute='_compute_instance_count',
help='Number of Odoo instances running on this cluster'
)
instance_ids = fields.One2many(
'k8s.odoo.instance',
'cluster_id',
string='Odoo Instances'
)
@api.depends('instance_ids')
def _compute_instance_count(self):
for cluster in self:
cluster.instance_count = len(cluster.instance_ids)
@api.constrains('namespace')
def _check_namespace(self):
for cluster in self:
if not re.match(r'^[a-z0-9]([-a-z0-9]*[a-z0-9])?$', cluster.namespace):
raise ValidationError(_("Namespace must consist of lowercase alphanumeric characters or '-', and must start and end with an alphanumeric character."))
def action_test_connection(self):
"""Test the connection to the Kubernetes cluster"""
self.ensure_one()
try:
# Create a temporary file for the kubeconfig if needed
temp_files = []
# Configure the Kubernetes client using kubeconfig
if not self.kubeconfig_data:
raise UserError(_('Kubeconfig file is required.'))
# Create a temporary file for the kubeconfig
kubeconfig_fd, kubeconfig_path = tempfile.mkstemp()
temp_files.append(kubeconfig_path)
with os.fdopen(kubeconfig_fd, 'wb') as f:
f.write(base64.b64decode(self.kubeconfig_data))
# Load the kubeconfig
kubernetes.config.load_kube_config(config_file=kubeconfig_path)
# Create API clients
core_api = kubernetes.client.CoreV1Api()
version_api = kubernetes.client.VersionApi()
# Test the connection by getting the cluster version
version_info = version_api.get_code()
# Get the node count
nodes = core_api.list_node()
node_count = len(nodes.items)
# Update the cluster information
self.write({
'connection_status': 'connected',
'connection_error': False,
'last_connection_test': fields.Datetime.now(),
'cluster_version': f"{version_info.major}.{version_info.minor}",
'node_count': node_count,
})
# Clean up temporary files
for temp_file in temp_files:
try:
os.unlink(temp_file)
except (OSError, IOError):
pass
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Connection Test Successful'),
'message': _('Successfully connected to Kubernetes cluster %s (version %s) with %s nodes.') % (
self.name, self.cluster_version, self.node_count
),
'sticky': False,
'type': 'success',
}
}
except ApiException as e:
# Clean up temporary files
for temp_file in temp_files:
try:
os.unlink(temp_file)
except (OSError, IOError):
pass
error_message = f"API Error: {e.reason}"
self.write({
'connection_status': 'failed',
'connection_error': error_message,
'last_connection_test': fields.Datetime.now(),
})
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Connection Test Failed'),
'message': error_message,
'sticky': False,
'type': 'danger',
}
}
except Exception as e:
# Clean up temporary files
for temp_file in temp_files:
try:
os.unlink(temp_file)
except (OSError, IOError):
pass
error_message = f"Error: {str(e)}"
self.write({
'connection_status': 'failed',
'connection_error': error_message,
'last_connection_test': fields.Datetime.now(),
})
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Connection Test Failed'),
'message': error_message,
'sticky': False,
'type': 'danger',
}
}
def get_k8s_client(self):
"""Get a configured Kubernetes client for this cluster"""
self.ensure_one()
# Create a temporary file for the kubeconfig if needed
temp_files = []
try:
# Configure the Kubernetes client using kubeconfig
if not self.kubeconfig_data:
raise UserError(_('Kubeconfig file is required.'))
# Create a temporary file for the kubeconfig
kubeconfig_fd, kubeconfig_path = tempfile.mkstemp()
temp_files.append(kubeconfig_path)
with os.fdopen(kubeconfig_fd, 'wb') as f:
f.write(base64.b64decode(self.kubeconfig_data))
# Load the kubeconfig
kubernetes.config.load_kube_config(config_file=kubeconfig_path)
# Create and return the API client
return kubernetes.client.ApiClient()
except Exception as e:
# Clean up temporary files
for temp_file in temp_files:
try:
os.unlink(temp_file)
except (OSError, IOError):
pass
raise UserError(_("Failed to configure Kubernetes client: %s") % str(e))
def action_view_instances(self):
"""View Odoo instances running on this cluster"""
self.ensure_one()
return {
'name': _('Odoo Instances'),
'type': 'ir.actions.act_window',
'res_model': 'k8s.odoo.instance',
'view_mode': 'tree,form',
'domain': [('cluster_id', '=', self.id)],
'context': {'default_cluster_id': self.id},
}
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if vals.get('name', _('New')) == _('New'):
vals['name'] = self.env['ir.sequence'].next_by_code('k8s.cluster') or _('New')
# Create attachment for kubeconfig
if 'kubeconfig_data' in vals and vals.get('kubeconfig_data'):
attachment_vals = {
'name': vals.get('kubeconfig_filename', 'kubeconfig'),
'type': 'binary',
'datas': vals['kubeconfig_data'],
'res_model': 'k8s.cluster',
'res_field': 'kubeconfig_data',
}
attachment = self.env['ir.attachment'].create(attachment_vals)
vals['kubeconfig_attachment_id'] = attachment.id
return super().create(vals_list)
def write(self, vals):
# Update attachment if kubeconfig changes
if 'kubeconfig_data' in vals and vals.get('kubeconfig_data'):
for record in self:
# Delete old attachment if it exists
if record.kubeconfig_attachment_id:
record.kubeconfig_attachment_id.unlink()
# Create new attachment
attachment_vals = {
'name': vals.get('kubeconfig_filename', record.kubeconfig_filename or 'kubeconfig'),
'type': 'binary',
'datas': vals['kubeconfig_data'],
'res_model': 'k8s.cluster',
'res_field': 'kubeconfig_data',
}
attachment = self.env['ir.attachment'].create(attachment_vals)
vals['kubeconfig_attachment_id'] = attachment.id
return super().write(vals)
def unlink(self):
# Delete attachments when records are deleted
attachments = self.mapped('kubeconfig_attachment_id')
result = super().unlink()
if result and attachments:
attachments.unlink()
return result

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,6 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_k8s_cluster_admin,k8s.cluster admin,model_k8s_cluster,bemade_k8s_odoo_manager.group_k8s_odoo_manager_admin,1,1,1,1
access_k8s_cluster_user,k8s.cluster user,model_k8s_cluster,bemade_k8s_odoo_manager.group_k8s_odoo_manager_user,1,0,0,0
access_k8s_odoo_instance_admin,k8s.odoo.instance admin,model_k8s_odoo_instance,bemade_k8s_odoo_manager.group_k8s_odoo_manager_admin,1,1,1,1
access_k8s_odoo_instance_user,k8s.odoo.instance user,model_k8s_odoo_instance,bemade_k8s_odoo_manager.group_k8s_odoo_manager_user,1,0,0,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_k8s_cluster_admin k8s.cluster admin model_k8s_cluster bemade_k8s_odoo_manager.group_k8s_odoo_manager_admin 1 1 1 1
3 access_k8s_cluster_user k8s.cluster user model_k8s_cluster bemade_k8s_odoo_manager.group_k8s_odoo_manager_user 1 0 0 0
4 access_k8s_odoo_instance_admin k8s.odoo.instance admin model_k8s_odoo_instance bemade_k8s_odoo_manager.group_k8s_odoo_manager_admin 1 1 1 1
5 access_k8s_odoo_instance_user k8s.odoo.instance user model_k8s_odoo_instance bemade_k8s_odoo_manager.group_k8s_odoo_manager_user 1 0 0 0

View file

@ -0,0 +1,85 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="0">
<!-- Security Groups -->
<record id="group_k8s_odoo_manager_user" model="res.groups">
<field name="name">Kubernetes Odoo Manager / User</field>
<field name="category_id" ref="base.module_category_administration"/>
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
</record>
<record id="group_k8s_odoo_manager_admin" model="res.groups">
<field name="name">Kubernetes Odoo Manager / Administrator</field>
<field name="category_id" ref="base.module_category_administration"/>
<field name="implied_ids" eval="[(4, ref('group_k8s_odoo_manager_user'))]"/>
<field name="users" eval="[(4, ref('base.user_admin'))]"/>
</record>
<!-- Record Rules -->
<record id="rule_k8s_cluster_admin" model="ir.rule">
<field name="name">Kubernetes Cluster: Administrators can see all clusters</field>
<field name="model_id" ref="model_k8s_cluster"/>
<field name="groups" eval="[(4, ref('group_k8s_odoo_manager_admin'))]"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="True"/>
</record>
<record id="rule_k8s_cluster_user" model="ir.rule">
<field name="name">Kubernetes Cluster: Users can only see active clusters</field>
<field name="model_id" ref="model_k8s_cluster"/>
<field name="groups" eval="[(4, ref('group_k8s_odoo_manager_user'))]"/>
<field name="domain_force">[('active', '=', True)]</field>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<record id="rule_k8s_odoo_instance_admin" model="ir.rule">
<field name="name">Kubernetes Odoo Instance: Administrators can see all instances</field>
<field name="model_id" ref="model_k8s_odoo_instance"/>
<field name="groups" eval="[(4, ref('group_k8s_odoo_manager_admin'))]"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="True"/>
</record>
<record id="rule_k8s_odoo_instance_user" model="ir.rule">
<field name="name">Kubernetes Odoo Instance: Users can only see active instances</field>
<field name="model_id" ref="model_k8s_odoo_instance"/>
<field name="groups" eval="[(4, ref('group_k8s_odoo_manager_user'))]"/>
<field name="domain_force">[('active', '=', True)]</field>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<record id="rule_k8s_odoo_upgrade_admin" model="ir.rule">
<field name="name">Kubernetes Odoo Upgrade: Administrators can see all upgrades</field>
<field name="model_id" ref="model_k8s_odoo_upgrade"/>
<field name="groups" eval="[(4, ref('group_k8s_odoo_manager_admin'))]"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="True"/>
</record>
<record id="rule_k8s_odoo_upgrade_user" model="ir.rule">
<field name="name">Kubernetes Odoo Upgrade: Users can only see upgrades for active instances</field>
<field name="model_id" ref="model_k8s_odoo_upgrade"/>
<field name="groups" eval="[(4, ref('group_k8s_odoo_manager_user'))]"/>
<field name="domain_force">[('instance_id.active', '=', True)]</field>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
</data>
</odoo>

View file

@ -0,0 +1,107 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Tree View -->
<record id="view_k8s_cluster_tree" model="ir.ui.view">
<field name="name">k8s.cluster.tree</field>
<field name="model">k8s.cluster</field>
<field name="arch" type="xml">
<tree string="Kubernetes Clusters">
<field name="name"/>
<field name="api_url"/>
<field name="kubeconfig_attachment_id"/>
<field name="active"/>
</tree>
</field>
</record>
<!-- Form View -->
<record id="view_k8s_cluster_form" model="ir.ui.view">
<field name="name">k8s.cluster.form</field>
<field name="model">k8s.cluster</field>
<field name="arch" type="xml">
<form string="Kubernetes Cluster">
<sheet>
<div class="oe_button_box" name="button_box">
<button name="action_test_connection" type="object" class="oe_stat_button" icon="fa-check-circle">
<span>Test Connection</span>
</button>
</div>
<div class="oe_title">
<h1>
<field name="name" placeholder="Cluster Name"/>
</h1>
</div>
<group>
<group>
<field name="api_url"/>
<field name="namespace"/>
<field name="default_tls_issuer"/>
<field name="active"/>
</group>
<group>
<field name="kubeconfig_attachment_id" options="{'no_create': True}"/>
</group>
</group>
<notebook>
<page string="Instances">
<field name="instance_ids">
<tree>
<field name="name"/>
<field name="namespace"/>
<field name="image"/>
<field name="status"/>
</tree>
</field>
</page>
<page string="Notes">
<field name="notes"/>
</page>
</notebook>
</sheet>
<div class="oe_chatter">
<field name="message_follower_ids"/>
<field name="activity_ids"/>
<field name="message_ids"/>
</div>
</form>
</field>
</record>
<!-- Search View -->
<record id="view_k8s_cluster_search" model="ir.ui.view">
<field name="name">k8s.cluster.search</field>
<field name="model">k8s.cluster</field>
<field name="arch" type="xml">
<search string="Search Kubernetes Clusters">
<field name="name"/>
<field name="api_url"/>
<separator/>
<filter string="Archived" name="inactive" domain="[('active', '=', False)]"/>
<group expand="0" string="Group By">
<filter string="Active" name="group_by_active" context="{'group_by': 'active'}"/>
</group>
</search>
</field>
</record>
<!-- Action -->
<record id="action_k8s_cluster" model="ir.actions.act_window">
<field name="name">Kubernetes Clusters</field>
<field name="res_model">k8s.cluster</field>
<field name="view_mode">tree,form</field>
<field name="search_view_id" ref="view_k8s_cluster_search"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create a new Kubernetes cluster
</p>
<p>
Define Kubernetes clusters to connect to for managing Odoo instances.
</p>
</field>
</record>
<!-- Menu Items -->
<menuitem id="menu_k8s_odoo_manager_root" name="K8s Odoo Manager" web_icon="bemade_k8s_odoo_manager,static/description/icon.png" sequence="90"/>
<menuitem id="menu_k8s_cluster" name="Kubernetes Clusters" parent="menu_k8s_odoo_manager_root" action="action_k8s_cluster" sequence="10"/>
</odoo>

View file

@ -0,0 +1,216 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Tree View -->
<record id="view_k8s_odoo_instance_tree" model="ir.ui.view">
<field name="name">k8s.odoo.instance.tree</field>
<field name="model">k8s.odoo.instance</field>
<field name="arch" type="xml">
<tree string="Odoo Instances" decoration-success="k8s_status=='running'" decoration-info="k8s_status=='pending'" decoration-warning="k8s_status=='upgrading'" decoration-danger="k8s_status=='error'">
<field name="name"/>
<field name="cluster_id"/>
<field name="namespace"/>
<field name="image"/>
<field name="k8s_status" string="Status"/>
<field name="k8s_exists"/>
<field name="k8s_pods_count"/>
<field name="k8s_ready_pods_count"/>
<field name="odoo_url" widget="url"/>
<field name="last_status_check"/>
</tree>
</field>
</record>
<!-- Form View -->
<record id="view_k8s_odoo_instance_form" model="ir.ui.view">
<field name="name">k8s.odoo.instance.form</field>
<field name="model">k8s.odoo.instance</field>
<field name="arch" type="xml">
<form string="Odoo Instance">
<header>
<button name="action_deploy" string="Deploy" type="object" class="oe_highlight" invisible="status not in ('draft', 'error')"/>
<button name="action_apply_changes" string="Apply Changes" type="object" class="oe_highlight" invisible="status not in ('running', 'error')"/>
<button name="action_refresh_status" string="Refresh Status" type="object" invisible="status == 'draft'"/>
<button name="action_view_upgrades" string="View Upgrades" type="object" invisible="status not in ('running', 'error')"/>
<field name="status" widget="statusbar" statusbar_visible="draft,pending,running,error,upgrading"/>
<field name="k8s_read_failed" invisible="1"/>
</header>
<sheet>
<div class="oe_button_box" name="button_box">
<button name="action_view_upgrades" type="object" class="oe_stat_button" icon="fa-refresh" invisible="status not in ('running', 'error')">
<field name="upgrade_ids" widget="statinfo" string="Upgrades"/>
</button>
</div>
<div class="alert alert-danger" role="alert" invisible="not k8s_read_failed">
<p>
<strong>Warning:</strong> Unable to read current configuration from Kubernetes. The current configuration values are not available. Please check your connection and click "Refresh Status" to try again.</p>
</div>
<div class="oe_title">
<h1>
<field name="name" placeholder="Instance Name"/>
</h1>
</div>
<group>
<group>
<field name="cluster_id" options="{'no_create': True}"/>
<field name="namespace"/>
<field name="description"/>
<field name="active"/>
</group>
<group>
<field name="odoo_url" widget="url"/>
<field name="status_message" readonly="1"/>
<field name="last_status_check" readonly="1"/>
<field name="master_password" password="True" placeholder="Set password for deployment and updates"/>
</group>
</group>
<notebook>
<page string="Docker Image">
<group>
<group string="Desired Configuration">
<field name="image" readonly="status not in ('draft', 'running', 'error')"/>
<field name="image_pull_secret" readonly="status not in ('draft', 'running', 'error')"/>
</group>
<group string="Current Kubernetes Configuration">
<field name="k8s_image" readonly="1"/>
<field name="k8s_image_pull_secret" readonly="1"/>
</group>
</group>
<div class="alert alert-info" role="alert" invisible="status not in ('running', 'error')">
<p>
<strong>Note:</strong> Edit the desired configuration fields and click "Apply Changes" to update the Kubernetes instance.</p>
</div>
</page>
<page string="Resources">
<group>
<group string="Desired CPU">
<field name="cpu_request" readonly="status not in ('draft', 'running', 'error')"/>
<field name="cpu_limit" readonly="status not in ('draft', 'running', 'error')"/>
</group>
<group string="Current CPU">
<field name="k8s_cpu_request" readonly="1"/>
<field name="k8s_cpu_limit" readonly="1"/>
</group>
</group>
<group>
<group string="Desired Memory">
<field name="memory_request" readonly="status not in ('draft', 'running', 'error')"/>
<field name="memory_limit" readonly="status not in ('draft', 'running', 'error')"/>
</group>
<group string="Current Memory">
<field name="k8s_memory_request" readonly="1"/>
<field name="k8s_memory_limit" readonly="1"/>
</group>
</group>
<group>
<group string="Desired Filestore">
<field name="filestore_size" readonly="status not in ('draft', 'running', 'error')"/>
<field name="filestore_storage_class" readonly="status not in ('draft', 'running', 'error')"/>
</group>
<group string="Current Filestore">
<field name="k8s_filestore_size" readonly="1"/>
<field name="k8s_filestore_storage_class" readonly="1"/>
</group>
</group>
<div class="alert alert-info" role="alert" invisible="status not in ('running', 'error')">
<p>
<strong>Note:</strong> Edit the desired configuration fields and click "Apply Changes" to update the Kubernetes instance.</p>
</div>
</page>
<page string="Ingress">
<group>
<group string="Desired Ingress Configuration">
<field name="host_names" readonly="status not in ('draft', 'running', 'error')"/>
<field name="tls_issuer" readonly="status not in ('draft', 'running', 'error')" placeholder="Uses cluster's default if empty"/>
</group>
<group string="Current Ingress Configuration">
<field name="k8s_host_names" readonly="1"/>
<field name="k8s_tls_issuer" readonly="1"/>
</group>
</group>
<div class="alert alert-info" role="alert" invisible="status not in ('running', 'error')">
<p>
<strong>Note:</strong> Edit the desired configuration fields and click "Apply Changes" to update the Kubernetes instance.</p>
</div>
</page>
<page string="Configuration">
<group>
<group string="Desired Odoo Configuration" colspan="1">
<field name="config_options" widget="ace" options="{'mode': 'yaml'}" readonly="status not in ('draft', 'running', 'error')"/>
</group>
<group string="Current Odoo Configuration" colspan="1">
<field name="k8s_config_options" widget="ace" options="{'mode': 'yaml'}" readonly="1"/>
</group>
</group>
<div class="alert alert-info" role="alert" invisible="status not in ('running', 'error')">
<p>
<strong>Note:</strong> Edit the desired configuration fields and click "Apply Changes" to update the Kubernetes instance.</p>
</div>
</page>
<page string="Kubernetes Status">
<group>
<group string="Current Status">
<field name="k8s_exists" readonly="1"/>
<field name="k8s_status" readonly="1"/>
<field name="k8s_status_message" readonly="1"/>
</group>
<group string="Pods">
<field name="k8s_pods_count" readonly="1"/>
<field name="k8s_ready_pods_count" readonly="1"/>
</group>
</group>
</page>
</notebook>
</sheet>
<div class="oe_chatter">
<field name="message_follower_ids"/>
<field name="activity_ids"/>
<field name="message_ids"/>
</div>
</form>
</field>
</record>
<!-- Search View -->
<record id="view_k8s_odoo_instance_search" model="ir.ui.view">
<field name="name">k8s.odoo.instance.search</field>
<field name="model">k8s.odoo.instance</field>
<field name="arch" type="xml">
<search string="Search Odoo Instances">
<field name="name"/>
<field name="cluster_id"/>
<field name="namespace"/>
<field name="image"/>
<separator/>
<filter string="Draft" name="draft" domain="[('status', '=', 'draft')]"/>
<filter string="Pending" name="pending" domain="[('status', '=', 'pending')]"/>
<filter string="Running" name="running" domain="[('status', '=', 'running')]"/>
<filter string="Error" name="error" domain="[('status', '=', 'error')]"/>
<filter string="Upgrading" name="upgrading" domain="[('status', '=', 'upgrading')]"/>
<group expand="0" string="Group By">
<filter string="Cluster" name="group_by_cluster" context="{'group_by': 'cluster_id'}"/>
<filter string="Namespace" name="group_by_namespace" context="{'group_by': 'namespace'}"/>
<filter string="Status" name="group_by_status" context="{'group_by': 'status'}"/>
</group>
</search>
</field>
</record>
<!-- Action -->
<record id="action_k8s_odoo_instance" model="ir.actions.act_window">
<field name="name">Odoo Instances</field>
<field name="res_model">k8s.odoo.instance</field>
<field name="view_mode">tree,form</field>
<field name="search_view_id" ref="view_k8s_odoo_instance_search"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create a new Odoo instance
</p>
<p>
Create and manage Odoo instances running on Kubernetes.
</p>
</field>
</record>
<!-- Menu Item -->
<menuitem id="menu_k8s_odoo_instance" name="Odoo Instances" parent="menu_k8s_odoo_manager_root" action="action_k8s_odoo_instance" sequence="20"/>
</odoo>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Include all view files -->
<data>
<!-- This is a placeholder file that includes all other view files -->
</data>
</odoo>

View file

@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
from . import k8s_odoo_instance_update_wizard

View file

@ -0,0 +1,148 @@
# -*- coding: utf-8 -*-
import logging
from odoo import models, fields, api, _
from odoo.exceptions import UserError, ValidationError
from kubernetes.client.rest import ApiException
_logger = logging.getLogger(__name__)
class K8sOdooUpgradeSimpleWizard(models.TransientModel):
_name = 'k8s.odoo.upgrade.simple.wizard'
_description = 'Simple Odoo Upgrade Wizard'
instance_id = fields.Many2one(
'k8s.odoo.instance',
string='Odoo Instance',
required=True,
readonly=True
)
database = fields.Char(
string='Database',
required=True,
help='Database name to upgrade'
)
modules = fields.Char(
string='Modules',
required=True,
help='Comma-separated list of modules to upgrade'
)
scheduled_time = fields.Datetime(
string='Scheduled Time',
help='When to run the upgrade (leave empty to run immediately)'
)
admin_password = fields.Char(
string='Admin Password',
required=True,
help='Admin password for the Odoo instance'
)
@api.model
def default_get(self, fields_list):
res = super().default_get(fields_list)
active_id = self.env.context.get('active_id')
if active_id:
instance = self.env['k8s.odoo.instance'].browse(active_id)
res['instance_id'] = instance.id
# Try to get the database name from the instance
if instance.k8s_config_options:
import yaml
try:
config = yaml.safe_load(instance.k8s_config_options)
if config and isinstance(config, dict) and 'db_name' in config:
res['database'] = config['db_name']
except Exception as e:
_logger.warning(f"Could not parse config options: {e}")
return res
def action_trigger_upgrade(self):
"""Trigger the upgrade in Kubernetes"""
self.ensure_one()
instance = self.instance_id
try:
# Get the Kubernetes client
api_client = instance.cluster_id.get_k8s_client()
custom_api = instance.cluster_id._get_custom_objects_api()
# Get the OdooInstance custom resource
try:
current_instance = custom_api.get_namespaced_custom_object(
group="bemade.org",
version="v1",
namespace=instance.namespace,
plural="odooinstances",
name=instance.name,
)
except ApiException as e:
if e.status == 404:
raise UserError(
_(
"The Odoo instance does not exist in Kubernetes."
)
)
else:
raise
# Set the admin password for the upgrade
current_instance["spec"]["adminPassword"] = self.admin_password
# Prepare the upgrade spec
upgrade_spec = {
"database": self.database,
"modules": [m.strip() for m in self.modules.split(',') if m.strip()]
}
# Add scheduled time if provided
if self.scheduled_time:
upgrade_spec["time"] = self.scheduled_time.isoformat()
# Add the upgrade spec to trigger the operator's upgrade process
current_instance["spec"]["upgrade"] = upgrade_spec
# Update the OdooInstance in Kubernetes
custom_api.patch_namespaced_custom_object(
group="bemade.org",
version="v1",
namespace=instance.namespace,
plural="odooinstances",
name=instance.name,
body=current_instance,
)
# Schedule a status check
self.env.ref(
"bemade_k8s_odoo_manager.ir_cron_check_instance_status"
).method_direct_trigger()
return {
"type": "ir.actions.client",
"tag": "display_notification",
"params": {
"title": _("Upgrade Triggered"),
"message": _("The upgrade process has been initiated."),
"sticky": False,
"type": "success",
"next": {
"type": "ir.actions.act_window_close",
},
},
}
except Exception as e:
error_message = f"Error: {str(e)}"
_logger.error(error_message)
return {
"type": "ir.actions.client",
"tag": "display_notification",
"params": {
"title": _("Error"),
"message": error_message,
"sticky": True,
"type": "danger",
},
}