first push message
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import logging
|
||||
import re
|
||||
import secrets
|
||||
import string
|
||||
import subprocess
|
||||
|
||||
import odoo
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SaasDatabase(models.Model):
|
||||
"""Flow 2: Database & Instance Provisioning Flow
|
||||
|
||||
System Generates Unique Database Name
|
||||
-> Create PostgreSQL Database (schema, user setup, permissions)
|
||||
-> Install Selected App Modules (website, crm, accounting, ...)
|
||||
-> Configure System Instance (workers, timeout, memory limits)
|
||||
-> Generate Admin Credentials & Send Email
|
||||
-> Instance Ready! (30-60 seconds)
|
||||
|
||||
Also documents the routing pipeline:
|
||||
1. User Request -> company1.domain-name.com
|
||||
2. DNS Resolution -> Load Balancer IP
|
||||
3. Load Balancer -> Route to available Worker
|
||||
4. Worker -> Read database name from subdomain
|
||||
5. Database Router -> Connect to correct tenant database
|
||||
6. Process Request -> Return response
|
||||
"""
|
||||
_name = 'saas.database'
|
||||
_description = 'SaaS Tenant Database / Instance'
|
||||
_inherit = ['mail.thread']
|
||||
_order = 'create_date desc'
|
||||
|
||||
trial_request_id = fields.Many2one('saas.trial.request', required=True, ondelete='cascade')
|
||||
company_name = fields.Char(required=True)
|
||||
app_ids = fields.Many2many('saas.app', string='Apps to Install')
|
||||
|
||||
# ---- Generated Database Name ----
|
||||
db_name = fields.Char('Database Name', copy=False, readonly=True, tracking=True)
|
||||
subdomain = fields.Char('Subdomain (FQDN)', copy=False, readonly=True, tracking=True)
|
||||
|
||||
# ---- Instance configuration ----
|
||||
worker_count = fields.Integer(default=2)
|
||||
timeout_seconds = fields.Integer(default=120)
|
||||
memory_limit_mb = fields.Integer(default=512)
|
||||
worker_node = fields.Char('Assigned Worker Node', readonly=True)
|
||||
|
||||
# ---- Admin credentials ----
|
||||
admin_login = fields.Char(readonly=True, copy=False)
|
||||
admin_password = fields.Char(readonly=True, copy=False) # store hashed/secret-managed in real prod
|
||||
|
||||
state = fields.Selection([
|
||||
('draft', 'Draft'),
|
||||
('name_generated', 'DB Name Generated'),
|
||||
('db_created', 'PostgreSQL Database Created'),
|
||||
('modules_installed', 'Apps Installed'),
|
||||
('configured', 'Instance Configured'),
|
||||
('credentials_sent', 'Admin Credentials Sent'),
|
||||
('ready', 'Instance Ready'),
|
||||
('error', 'Provisioning Error'),
|
||||
], default='draft', tracking=True)
|
||||
|
||||
error_message = fields.Text()
|
||||
provisioning_seconds = fields.Float('Provisioning Duration (s)', readonly=True)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# MAIN ENTRY POINT - runs the whole Flow 2 pipeline
|
||||
# ------------------------------------------------------------------
|
||||
def action_provision(self):
|
||||
self.ensure_one()
|
||||
start = fields.Datetime.now()
|
||||
try:
|
||||
self._generate_unique_db_name()
|
||||
self._create_postgres_database()
|
||||
self._install_selected_modules()
|
||||
self._configure_instance()
|
||||
self._generate_admin_credentials()
|
||||
self._send_credentials_email()
|
||||
self.state = 'ready'
|
||||
self.trial_request_id.state = 'ready'
|
||||
except Exception as e:
|
||||
_logger.exception("Provisioning failed for %s", self.company_name)
|
||||
self.write({'state': 'error', 'error_message': str(e)})
|
||||
self.trial_request_id.state = 'failed'
|
||||
raise UserError(_("Provisioning failed: %s") % e)
|
||||
finally:
|
||||
end = fields.Datetime.now()
|
||||
self.provisioning_seconds = (end - start).total_seconds()
|
||||
return True
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Step 1: System Generates Unique Database Name
|
||||
# e.g. company-name12345.domain-name.com
|
||||
# ------------------------------------------------------------------
|
||||
def _generate_unique_db_name(self):
|
||||
self.ensure_one()
|
||||
base_slug = re.sub(r'[^a-z0-9]+', '-', self.company_name.lower()).strip('-') or 'tenant'
|
||||
suffix = ''.join(secrets.choice(string.digits) for _ in range(5))
|
||||
candidate = f"{base_slug}{suffix}"
|
||||
|
||||
# Ensure global uniqueness against existing tenant records
|
||||
while self.search_count([('db_name', '=', candidate)]):
|
||||
suffix = ''.join(secrets.choice(string.digits) for _ in range(5))
|
||||
candidate = f"{base_slug}{suffix}"
|
||||
|
||||
main_domain = self.env['ir.config_parameter'].sudo().get_param(
|
||||
'saas_trial_portal.main_domain', default='domain-name.com'
|
||||
)
|
||||
self.write({
|
||||
'db_name': candidate,
|
||||
'subdomain': f"{candidate}.{main_domain}",
|
||||
'state': 'name_generated',
|
||||
})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Step 2: Create PostgreSQL Database (schema, user setup, permissions)
|
||||
# ------------------------------------------------------------------
|
||||
def _create_postgres_database(self):
|
||||
"""Uses Odoo's own database service to create + initialize a new
|
||||
tenant database. Equivalent to running:
|
||||
createdb -O <db_user> <db_name>
|
||||
odoo-bin -d <db_name> -i base --stop-after-init
|
||||
but done in-process via odoo.service.db so it integrates with the
|
||||
running Odoo instance (same approach used by odoo.com signup flow
|
||||
and the /web/database/manager screens).
|
||||
"""
|
||||
self.ensure_one()
|
||||
db_name = self.db_name
|
||||
|
||||
# Security / permissions: dedicated low-privilege role per tenant
|
||||
# could be created here via a raw psycopg2 connection to "postgres"
|
||||
# maintenance DB if your infra requires per-tenant DB roles:
|
||||
#
|
||||
# import psycopg2
|
||||
# conn = psycopg2.connect(dbname='postgres')
|
||||
# conn.autocommit = True
|
||||
# cur = conn.cursor()
|
||||
# cur.execute(f'CREATE ROLE "{db_name}_role" LOGIN PASSWORD %s', [secrets.token_urlsafe(16)])
|
||||
# cur.execute(f'CREATE DATABASE "{db_name}" OWNER "{db_name}_role"')
|
||||
#
|
||||
# For a single shared DB-user setup (default Odoo install), we let
|
||||
# Odoo's db service create+initialize the database directly:
|
||||
|
||||
try:
|
||||
from odoo.service import db as db_service
|
||||
db_service.exp_create_database(
|
||||
db_name,
|
||||
demo=False,
|
||||
lang='en_US',
|
||||
user_password=None, # set after init, see _generate_admin_credentials
|
||||
login=None,
|
||||
country_code=None,
|
||||
phone=None,
|
||||
)
|
||||
except Exception as e:
|
||||
raise UserError(_("Could not create PostgreSQL database '%s': %s") % (db_name, e))
|
||||
|
||||
self.state = 'db_created'
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Step 3: Install Selected App Modules (website, crm, accounting...)
|
||||
# ------------------------------------------------------------------
|
||||
def _install_selected_modules(self):
|
||||
self.ensure_one()
|
||||
module_names = self.app_ids.mapped('technical_module_name')
|
||||
if not module_names:
|
||||
self.state = 'modules_installed'
|
||||
return
|
||||
|
||||
# Open a registry/cursor on the NEW tenant database and install modules
|
||||
registry = odoo.modules.registry.Registry.new(self.db_name, update_module=True)
|
||||
with registry.cursor() as cr:
|
||||
env = api.Environment(cr, odoo.SUPERUSER_ID, {})
|
||||
modules = env['ir.module.module'].search([
|
||||
('name', 'in', module_names),
|
||||
('state', '=', 'uninstalled'),
|
||||
])
|
||||
if modules:
|
||||
modules.button_immediate_install()
|
||||
cr.commit()
|
||||
|
||||
self.state = 'modules_installed'
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Step 4: Configure System Instance (workers, timeout, memory limits)
|
||||
# ------------------------------------------------------------------
|
||||
def _configure_instance(self):
|
||||
"""In a multi-worker / multi-tenant deployment this writes the
|
||||
per-tenant resource limits to your orchestration layer (e.g. a
|
||||
Kubernetes ConfigMap, systemd template unit, or a row in a
|
||||
'workers pool' table read by your reverse proxy / load balancer).
|
||||
Here we persist it on the record and on ir.config_parameter so the
|
||||
Database Router (see routing pipeline in class docstring) can read it.
|
||||
"""
|
||||
self.ensure_one()
|
||||
self.write({
|
||||
'worker_node': self._assign_worker_node(),
|
||||
})
|
||||
self.state = 'configured'
|
||||
|
||||
def _assign_worker_node(self):
|
||||
"""Simplified round-robin assignment across configured worker
|
||||
node names. Replace with real load-balancer / k8s scheduler call.
|
||||
"""
|
||||
nodes_param = self.env['ir.config_parameter'].sudo().get_param(
|
||||
'saas_trial_portal.worker_nodes', default='worker-1,worker-2,worker-3'
|
||||
)
|
||||
nodes = [n.strip() for n in nodes_param.split(',') if n.strip()]
|
||||
count = self.search_count([('worker_node', '!=', False)])
|
||||
return nodes[count % len(nodes)] if nodes else 'worker-1'
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Step 5: Generate Admin Credentials & Send Email
|
||||
# ------------------------------------------------------------------
|
||||
def _generate_admin_credentials(self):
|
||||
self.ensure_one()
|
||||
password = secrets.token_urlsafe(12)
|
||||
login = self.trial_request_id.email
|
||||
|
||||
registry = odoo.modules.registry.Registry.new(self.db_name)
|
||||
with registry.cursor() as cr:
|
||||
env = api.Environment(cr, odoo.SUPERUSER_ID, {})
|
||||
admin_user = env.ref('base.user_admin', raise_if_not_found=False)
|
||||
if admin_user:
|
||||
admin_user.write({'login': login, 'name': self.trial_request_id.name})
|
||||
admin_user.sudo().write({'password': password})
|
||||
cr.commit()
|
||||
|
||||
self.write({'admin_login': login, 'admin_password': password})
|
||||
|
||||
def _send_credentials_email(self):
|
||||
self.ensure_one()
|
||||
template = self.env.ref('saas_trial_portal.mail_template_instance_ready')
|
||||
template.send_mail(self.id, force_send=True)
|
||||
self.state = 'credentials_sent'
|
||||
Reference in New Issue
Block a user