Coverage for ingadhoc-odoo-saas-adhoc / saas_provider_upgrade / models / saas_database.py: 10%

323 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-03-09 20:33 +0000

1############################################################################## 

2# For copyright and license notices, see __manifest__.py file in module root 

3# directory 

4############################################################################## 

5 

6import io 

7import json 

8import logging 

9import tempfile 

10import time 

11from math import ceil 

12from pathlib import Path 

13from posixpath import dirname, join 

14from typing import TYPE_CHECKING 

15 

16import requests 

17from markupsafe import Markup 

18from odoo import Command, _, fields, models 

19from odoo.exceptions import UserError, ValidationError 

20from odoo.tools.safe_eval import safe_eval 

21from odooly import Client 

22 

23if TYPE_CHECKING: 

24 from ..models.helpdesk_ticket_upgrade_request import HelpdeskTicketUpgradeRequest as UpgradeRequest 

25 

26_logger = logging.getLogger(__name__) 

27 

28 

29class _TimeoutClient(Client): 

30 """Client subclass that injects a connection timeout into the underlying 

31 requests.Session without modifying any global state (thread-safe). 

32 

33 When odooly's ``Client.__init__`` assigns ``self._http = HTTPSession()``, 

34 our ``__setattr__`` override intercepts that assignment and patches only 

35 **that specific** ``requests.Session`` instance to inject the timeout. 

36 No class-level or global state is modified, so concurrent threads are 

37 not affected. 

38 """ 

39 

40 def __init__(self, *args, _timeout=10, **kwargs): 

41 object.__setattr__(self, "_connection_timeout", _timeout) 

42 super().__init__(*args, **kwargs) 

43 

44 def __setattr__(self, name, value): 

45 super().__setattr__(name, value) 

46 if name == "_http" and hasattr(value, "_session") and hasattr(value._session, "request"): 

47 timeout = object.__getattribute__(self, "_connection_timeout") 

48 if not timeout: 

49 return 

50 original_request = value._session.request 

51 

52 def _request_with_timeout(*args, **kwargs): 

53 kwargs.setdefault("timeout", timeout) 

54 return original_request(*args, **kwargs) 

55 

56 value._session.request = _request_with_timeout 

57 

58 

59class SaasDatabase(models.Model): 

60 _inherit = "saas.database" 

61 

62 task_state = fields.Selection( 

63 selection_add=[ 

64 ("odooupgrade_submit_odoo_upgrade_task", "Odoo Upgrade: Dump and Submit Request"), 

65 # Odoo restore (restore upgraded database) 

66 ("odoorestore_context_setup", "Odoo Restore: Prepare database and filestore for restore"), 

67 ("odoorestore_restore_from_odoo_upgrade", "Odoo Restore: Restore migrated database"), 

68 ("odoorestore_context_setup_cleaning", "Odoo Restore: Cleaning"), 

69 ("odoorestore_update_app", "Odoo Restore: Deploy"), 

70 ("odoorestore_sync_system_parameters", "Odoo Restore: Sync system parameters"), 

71 ("odoorestore_post_restore_settings", "Odoo Restore: Post restore settings"), 

72 # OBA Upgrade 

73 ("obaupgrade_upgrade", "OBA Upgrade: Upgrade Database"), 

74 # Post Upgrade 

75 ("postupgrade_finalize_upgrade", "Post Upgrade: Finalize Upgrade Process"), 

76 # Upgrade Script run 

77 ("scriptupgrade_run_upgrade_line", "Script Upgrade: Run Script"), 

78 # Generate Snapshot of Productive Database 

79 ("upgrade-prod-backup_create_snapshot", "Upgrade Prod Backup: Create snapshot"), 

80 ("upgrade-prod-backup_check_snapshot_state", "Upgrade Prod Backup: Check snapshot state"), 

81 ("upgrade-prod-backup_assign_snapshot_to_request", "Upgrade Prod Backup: Assign snapshot to request"), 

82 # Drop Productive Database 

83 ("upgrade-done_drop", "Upgrade Done: Drop production database"), 

84 ("upgrade-done_duplicate_new", "Upgrade Done: Create new production database"), 

85 # Duplicate New as Production 

86 ("upgrade-deploy-prod_odoo_copy", "Upgrade Deploy Prod: Duplicate Odoo"), 

87 ("upgrade-deploy-prod_sync_system_parameters", "Upgrade Deploy Prod: Sync system parameters"), 

88 ("upgrade-deploy-prod_update_app", "Upgrade Deploy Prod: Install app"), 

89 ("upgrade-deploy-prod_fix_notifications", "Upgrade Deploy Prod: Fix Notifications"), 

90 ("upgrade-deploy-prod_final_setup_request", "Upgrade Deploy Prod: Final Setup Request"), 

91 ("upgrade-deploy-prod_chain_scripts_execution", "Upgrade Deploy Prod: Chain scripts execution"), 

92 # Generate Snapshot of Intermediate Database 

93 ("upgrade-int-backup_create_snapshot", "Upgrade Int Backup: Create snapshot"), 

94 ("upgrade-int-backup_check_snapshot_state", "Upgrade Int Backup: Check snapshot state"), 

95 ("upgrade-int-backup_assign_snapshot_to_request", "Upgrade Int Backup: Assign snapshot to request"), 

96 # Production Rollback 

97 ("upgrade-rollback_reactivate", "Upgrade Rollback: Reactivate production database"), 

98 ("upgrade-rollback_notify_rollback", "Upgrade Rollback: Notify about rollback"), 

99 ], 

100 ) 

101 

102 def _action_duplicate_as_prod(self, request: "UpgradeRequest", callback: str | None = None) -> "SaasDatabase": 

103 """ 

104 Action to duplicate the database as production. This is used in the upgrade process 

105 when we need to create a new database from the upgraded one and set it as production. 

106 

107 :param request: The upgrade request related to the duplication 

108 :return: The duplicated database with production type 

109 """ 

110 self.ensure_one() 

111 request.ensure_one() 

112 main_database = request.ticket_id.main_database_id 

113 update_to_latest_once = ( 

114 request.upgrade_type_id.new_version_id 

115 or request.ticket_id.upgrade_pull_request_ids.filtered(lambda r: r.state == "merged") 

116 ) 

117 

118 # Prepare default vals for the new database 

119 default = { 

120 "database_type_id": self.env["saas.database.type"].search([("is_production", "=", True)], limit=1).id, 

121 "analytic_account_id": main_database.analytic_account_id.id, 

122 "replica_count": main_database.replica_count, 

123 "pull_ids": [Command.link(pull.id) for pull in request.upgrade_type_id.pull_ids], 

124 "update_to_latest_once": update_to_latest_once, 

125 "remove_merged_pulls": update_to_latest_once, 

126 } 

127 

128 # Use sudo to copy private fields like `instance_password` 

129 new_db = self.sudo().copy(default) 

130 

131 # Copy custom domains and process them to add the new ones and remove the old ones from Cloudflare 

132 main_database.all_custom_domain_ids._write( 

133 { 

134 "database_id": new_db.id, 

135 } 

136 ) 

137 main_database.invalidate_recordset(["custom_domain_ids", "all_custom_domain_ids"]) 

138 new_db.invalidate_recordset(["custom_domain_ids", "all_custom_domain_ids"]) 

139 new_db.custom_domain_ids._write({"active": True}) 

140 new_db._compute_main_hostname() 

141 

142 new_db.message_post(body=_("Database duplicated from %s") % self.name) 

143 

144 # Initialize the duplicate background tasks 

145 new_db._schedule_task( 

146 "upgrade-deploy-prod", database_id=self.id, helpdesk_ticket_upgrade_request_id=request.id, callback=callback 

147 ) 

148 return new_db 

149 

150 def get_client_cluster( 

151 self, not_database: bool = False, attempts: int = 3, sleep: int = 3, timeout: int = 0 

152 ) -> Client: 

153 """ 

154 Method to get an Odooly client connected to the cluster where the database is hosted. 

155 Uses an iterative retry loop instead of recursion to be clearer and more maintainable. 

156 

157 :param not_database: If True, it will not connect to the database, just to the cluster. 

158 :param attempts: Number of attempts to connect to the cluster (default: 3). 

159 :param sleep: Time in seconds to wait between attempts (default: 3). 

160 :param timeout: Timeout in seconds for each connection attempt. 0 means no timeout (default: 0). 

161 :return: An Odooly client connected to the cluster. 

162 """ 

163 self.ensure_one() 

164 if self.state in ["draft", "deleted"]: 

165 raise UserError(_("You can't connect to databases in draft or deleted state. Db: %s") % self.name) 

166 

167 hostname = self._get_cluster_hostname() 

168 _logger.info("Getting client with host %s, attempts %s", hostname, attempts) 

169 attempts = max(1, attempts) 

170 for attempt in range(1, attempts + 1): 

171 try: 

172 return self._get_client_cluster(hostname, not_database, timeout=timeout) 

173 except Exception as e: 

174 if attempt < attempts: 

175 _logger.warning( 

176 "Attempt %s/%s to get client for %s failed: %s. Retrying in %ss", 

177 attempt, 

178 attempts, 

179 hostname, 

180 e, 

181 sleep, 

182 ) 

183 time.sleep(sleep) 

184 else: 

185 _logger.error("All %s attempts failed getting client for %s: %s", attempts, hostname, e) 

186 raise UserError(_("Error: %s") % e) 

187 

188 def _get_client_cluster(self, hostname: str, not_database: bool = False, timeout: int = 0): 

189 """ 

190 Private method to get an Odooly client connected to the cluster where the database is hosted. 

191 

192 :param hostname: The hostname of the cluster. 

193 :param not_database: If True, it will not connect to the database 

194 :param timeout: Timeout in seconds for the connection attempt. 0 means no timeout (default: 0). 

195 :return: An Odooly client connected to the cluster. 

196 """ 

197 self.ensure_one() 

198 user = self.env.context.get("force_client_user", self._get_superuser_login()) 

199 password = self._get_client_login_password(user) 

200 hostname += "/jsonrpc" 

201 ClientClass = _TimeoutClient if timeout else Client 

202 client_kwargs = {"_timeout": timeout} if timeout else {} 

203 try: 

204 if not_database: 

205 return ClientClass(hostname, **client_kwargs) 

206 return ClientClass( 

207 hostname, 

208 db=self._get_pg_db_name(), 

209 user=user, 

210 password=password, 

211 **client_kwargs, 

212 ) 

213 except (requests.exceptions.Timeout, requests.exceptions.ConnectTimeout, requests.exceptions.ReadTimeout): 

214 raise UserError( 

215 self.env._("Connection to Database '%s' via cluster timed out after %s seconds.") % (self.name, timeout) 

216 ) 

217 except Exception as e: 

218 raise UserError(self.env._("Unable to Connect to Database '%s' via cluster. Error: %s") % (self.name, e)) 

219 

220 def _retrieve_upgrade_ssh_keys(self) -> tuple[str, str]: 

221 """ 

222 Retrieve the SSH keys used to connect to odoo servers for upgrades 

223 

224 :return: Tuple of (private_key, public_key) 

225 """ 

226 get_param = self.env["ir.config_parameter"].sudo().get_param 

227 key_private = get_param("saas_provider_upgrade.upgrade_ssh_key") 

228 key_public = get_param("saas_provider_upgrade.upgrade_ssh_key_pub") 

229 return key_private, key_public 

230 

231 def _set_failed_task(self, message: str): 

232 """ 

233 Set the request as failed 

234 

235 :param message: The message to display 

236 """ 

237 if self.task_kwargs: 

238 error = Markup(_("A task failed on the database %s <blockquote>%s</blockquote>")) % ( 

239 Markup(self._get_html_link(title=self.display_name)), 

240 message, 

241 ) 

242 run_info_id = self.task_kwargs.get("run_info_id", None) 

243 request_id = self.task_kwargs.get("helpdesk_ticket_upgrade_request_id", None) 

244 run_info = self.env["saas.upgrade.line.request.run"].browse(run_info_id) 

245 request = self.env["helpdesk.ticket.upgrade.request"].browse(request_id) 

246 # Notify only one case to avoid duplicates 

247 if run_info.exists(): 

248 run_info.set_error(error) 

249 elif request.exists(): 

250 request._set_error_and_notify(error) 

251 

252 return super()._set_failed_task(message) 

253 

254 def _call_odoo_reload(self) -> None: 

255 """ 

256 Call the Odoo reload endpoint for versions 19.0 and above. 

257 """ 

258 self.ensure_one() 

259 if float(self.odoo_version_group_id.name) >= 19.0: 

260 hostname = self._get_cluster_hostname() 

261 url = f"{hostname}/saas/reload_registry" 

262 try: 

263 _logger.info("Calling reload endpoint: %s", url) 

264 response = requests.post( 

265 url, 

266 json={ 

267 "jsonrpc": "2.0", 

268 "method": "call", 

269 "params": {}, 

270 }, 

271 headers={"Content-Type": "application/json"}, 

272 timeout=30, 

273 ) 

274 response_data = response.json().get("result") 

275 if response_data.get("status", "") == "error": 

276 _logger.warning("Reload endpoint returned status error: %s", response_data.get("message", "")) 

277 else: 

278 _logger.info("Reload endpoint called successfully") 

279 except Exception as e: 

280 _logger.warning("Failed to call reload endpoint: %s", e) 

281 

282 def _get_upgrade_callback_url(self) -> str: 

283 """ 

284 Get the callback URL for upgrade notifications. 

285 

286 :return: Upgrade callback url 

287 """ 

288 callback_url = "%s/saas_provider_upgrade/upgrade_notify" % (self.get_base_url()) 

289 return callback_url 

290 

291 def _get_cluster_hostname(self) -> str: 

292 """ 

293 Get the cluster hostname for the database. 

294 

295 :return: Cluster hostname 

296 """ 

297 self.ensure_one() 

298 return f"http://{self.name}-adhoc-odoo-http.{self.name}.svc.cluster.local" 

299 

300 ####################### 

301 # K8S Upgrade Subtasks 

302 ####################### 

303 

304 def _submit_odoo_upgrade_task(self): 

305 """ 

306 Task to submit a database to be upgraded by Odoo SA 

307 Upon success, the upgrade token will be stored in the helpdesks ticket `odoo_request_token` field. 

308 

309 Task kwargs: 

310 :helpdesk_ticket_upgrade_request_id: The ID of the helpdesk ticket upgrade request 

311 """ 

312 request = None 

313 if self.task_kwargs: 

314 request_id = self.task_kwargs.get("helpdesk_ticket_upgrade_request_id", None) 

315 request = self.env["helpdesk.ticket.upgrade.request"].browse(request_id).exists() 

316 

317 request.ensure_one() 

318 

319 if self.environment_id.postgres_mode == "shared" and self.postgres_server_id: 

320 pg_size_curr = ceil(self.postgres_server_id._get_schema_size(dbname=self.name) / 1000**3) # in GB 

321 pg_size = max(ceil(pg_size_curr * 1.2), pg_size_curr + 1) 

322 if pg_size > self.pg_storage_size: 

323 self.pg_storage_size = self.analytic_account_id.pg_storage_size = pg_size 

324 

325 key_private, key_public = self._retrieve_upgrade_ssh_keys() 

326 callback_url = self._get_upgrade_callback_url() 

327 

328 # Search for the enterprise code 

329 enterprise_code = request.original_database_id.sudo()._run_sql_get_param("database.enterprise_code") 

330 # If main_database does not have enterprise_code and we are testing, use the original database 

331 if not enterprise_code: 

332 raise UserError( 

333 "The database %s does not have an enterprise code. Please contact Odoo support." 

334 % request.ticket_id.main_database_id.name 

335 ) 

336 

337 script_vars = { 

338 "MYSCRIPT_KEY_PRIVATE": key_private, 

339 "MYSCRIPT_KEY_PUBLIC": key_public, 

340 "MYSCRIPT_ENTERPRISE_CODE": enterprise_code, 

341 "MYSCRIPT_TARGET": request.upgrade_type_id.target, 

342 "MYSCRIPT_AIM": request.odoo_aim, 

343 "MYSCRIPT_ENV_VARS": request.ticket_id.environment_variables_dict, 

344 "MYSCRIPT_HOOK_URL": callback_url, 

345 "MYSCRIPT_HOOK_PARAMS": json.dumps( 

346 { 

347 "request_id": request.id, 

348 "step_type": "submit", 

349 "token": self.env["ir.config_parameter"].sudo().get_param("saas_provider.odoo_project_token"), 

350 } 

351 ), 

352 } 

353 

354 script_file_path = Path(join(dirname(__file__), "../scripts", "odoo_odoo_submit_request.py")) 

355 final_script = io.StringIO() 

356 with open(script_file_path, encoding="utf-8") as f: 

357 final_script.write(f.read()) 

358 final_script = final_script.getvalue() 

359 script_file_path = Path(join(dirname(__file__), "..", "UpgradeScript.py")) 

360 with open(script_file_path, encoding="utf-8") as f: 

361 final_script = final_script.replace( 

362 "### [PLACEHOLDER UpgradeScript] ###", f.read().replace("def main():", "def noname():") 

363 ) 

364 

365 with tempfile.NamedTemporaryFile(mode="w+", encoding="utf-8", delete=True) as tmpfile: 

366 tmpfile.write(final_script) 

367 tmpfile.flush() 

368 tmpfile.seek(0) 

369 return self.env["saas.database.task"]._create_k8s_job( 

370 database=self, 

371 script_file_name=Path(tmpfile.name), 

372 script_vars=script_vars, 

373 name_prefix="submit", 

374 mount_filestore=True, 

375 ) 

376 

377 def _restore_from_odoo_upgrade(self): 

378 """ 

379 Restore a database from an Odoo upgrade request 

380 

381 Task kwargs: 

382 :helpdesk_ticket_upgrade_request_id: The ID of the helpdesk ticket upgrade request 

383 """ 

384 request = None 

385 if self.task_kwargs: 

386 request_id = self.task_kwargs.get("helpdesk_ticket_upgrade_request_id", None) 

387 request = self.env["helpdesk.ticket.upgrade.request"].browse(request_id).exists() 

388 

389 request.ensure_one() 

390 

391 # Ensure we have an Odoo Upgrade token 

392 if not request.odoo_request_token: 

393 raise ValidationError(_("The Odoo Upgrade token is missing")) 

394 if self.environment_id.postgres_mode != "cnpg": 

395 # Check if the database already exists, before even attempting to download the dump 

396 if self.sudo().postgres_server_id._check_database_exists(self.name): 

397 raise ValidationError(_("The database %r already exists", self.name)) 

398 if self.environment_id.type != "k8s": 

399 raise NotImplementedError("El método _restore_from_odoo_upgrade solo está implementado para k8s") 

400 

401 key_private, key_public = self._retrieve_upgrade_ssh_keys() 

402 callback_url = self._get_upgrade_callback_url() 

403 parameters = dict(safe_eval(request.ticket_id.custom_parameters_dict or "{}")) or {} 

404 script_vars = { 

405 "MYSCRIPT_KEY_PRIVATE": key_private, 

406 "MYSCRIPT_KEY_PUBLIC": key_public, 

407 "BACKUP_FILESTORE_MODE": self.environment_id.filestore_mode, 

408 "NO_FORCE_STORAGE": parameters.get("no_force_storage", False), 

409 "MYSCRIPT_REQ_URI": request.odoo_host_uri, 

410 "MYSCRIPT_REQ_TOKEN": request.odoo_request_token, 

411 "MYSCRIPT_REQ_AIM": request.odoo_aim, 

412 "MYSCRIPT_HOOK_URL": callback_url, 

413 "MYSCRIPT_HOOK_PARAMS": json.dumps( 

414 { 

415 "request_id": request.id, 

416 "step_type": "restore", 

417 "token": self.env["ir.config_parameter"].sudo().get_param("saas_provider.odoo_project_token"), 

418 } 

419 ), 

420 } 

421 

422 script_file_path = Path(join(dirname(__file__), "../scripts", "odoo_odoorestore.py")) 

423 final_script = io.StringIO() 

424 with open(script_file_path, encoding="utf-8") as f: 

425 final_script.write(f.read()) 

426 final_script = final_script.getvalue() 

427 script_file_path = Path(join(dirname(__file__), "..", "UpgradeScript.py")) 

428 with open(script_file_path, encoding="utf-8") as f: 

429 final_script = final_script.replace( 

430 "### [PLACEHOLDER UpgradeScript] ###", f.read().replace("def main():", "def noname():") 

431 ) 

432 

433 with tempfile.NamedTemporaryFile(mode="w+", encoding="utf-8", delete=True) as tmpfile: 

434 tmpfile.write(final_script) 

435 tmpfile.flush() 

436 tmpfile.seek(0) 

437 return self.env["saas.database.task"]._create_k8s_job( 

438 database=self, 

439 script_file_name=Path(tmpfile.name), 

440 script_vars=script_vars, 

441 name_prefix="restore", 

442 mount_filestore=True, 

443 ) 

444 

445 def _post_restore_settings(self): 

446 """ 

447 Post restore settings. This task is called after the restore from Odoo upgrade request. 

448 It will set the database to the correct state and update the system parameters. 

449 

450 Task kwargs: 

451 :helpdesk_ticket_upgrade_request_id: The ID of the helpdesk ticket upgrade request 

452 """ 

453 request = None 

454 if self.task_kwargs: 

455 request_id = self.task_kwargs.get("helpdesk_ticket_upgrade_request_id", None) 

456 request = self.env["helpdesk.ticket.upgrade.request"].browse(request_id).exists() 

457 

458 request.ensure_one() 

459 request._next_state() 

460 

461 def _upgrade(self): 

462 """ 

463 Upgrade the database using the Odoo upgrade script 

464 

465 Task kwargs: 

466 :helpdesk_ticket_upgrade_request_id: The ID of the helpdesk ticket upgrade request 

467 """ 

468 request = None 

469 if self.task_kwargs: 

470 request_id = self.task_kwargs.get("helpdesk_ticket_upgrade_request_id", None) 

471 request = self.env["helpdesk.ticket.upgrade.request"].browse(request_id).exists() 

472 

473 request.ensure_one() 

474 up_type = request.upgrade_type_id 

475 callback_url = self._get_upgrade_callback_url() 

476 script_vars = { 

477 "MYSCRIPT_ODOO_UPGRADE_REPO": up_type.odoo_upgrade_repo, 

478 "MYSCRIPT_FROM_VERSION": request.original_database_id.odoo_version_group_id.name, 

479 "MYSCRIPT_TO_VERSION": up_type.target_odoo_version_group_id.name, 

480 "MYSCRIPT_HOOK_URL": callback_url, 

481 "MYSCRIPT_HOOK_PARAMS": json.dumps( 

482 { 

483 "request_id": request.id, 

484 "step_type": "upgrade", 

485 "token": self.env["ir.config_parameter"].sudo().get_param("saas_provider.odoo_project_token"), 

486 } 

487 ), 

488 } 

489 

490 script_file_path = Path(join(dirname(__file__), "../scripts", "odoo_obaupgrade.py")) 

491 final_script = io.StringIO() 

492 with open(script_file_path, encoding="utf-8") as f: 

493 final_script.write(f.read()) 

494 final_script = final_script.getvalue() 

495 

496 with tempfile.NamedTemporaryFile(mode="w+", encoding="utf-8", delete=True) as tmpfile: 

497 tmpfile.write(final_script) 

498 tmpfile.flush() 

499 tmpfile.seek(0) 

500 return self.env["saas.database.task"]._create_k8s_job( 

501 database=self, 

502 script_file_name=Path(tmpfile.name), 

503 script_vars=script_vars, 

504 name_prefix="obaupgrade", 

505 mount_filestore=True, 

506 ) 

507 

508 def _finalize_upgrade(self): 

509 """ 

510 Finalize upgrade process - handle bucket and attachments 

511 

512 Task kwargs: 

513 :helpdesk_ticket_upgrade_request_id: The ID of the helpdesk ticket upgrade request 

514 """ 

515 request = None 

516 if self.task_kwargs: 

517 request_id = self.task_kwargs.get("helpdesk_ticket_upgrade_request_id", None) 

518 request = self.env["helpdesk.ticket.upgrade.request"].browse(request_id).exists() 

519 

520 request.ensure_one() 

521 callback_url = self._get_upgrade_callback_url() 

522 parameters = dict(safe_eval(request.ticket_id.custom_parameters_dict or "{}")) or {} 

523 force_storage = ( 

524 not parameters.get("no_force_storage", False) 

525 and self.environment_id.bucket_config_id 

526 and not self.is_production 

527 and self.environment_id.filestore_mode == "attachment_s3" 

528 ) 

529 script_vars = { 

530 "MYSCRIPT_HOOK_URL": callback_url, 

531 "MYSCRIPT_HOOK_PARAMS": json.dumps( 

532 { 

533 "request_id": request.id, 

534 "step_type": "postupgrade", 

535 "token": self.env["ir.config_parameter"].sudo().get_param("saas_provider.odoo_project_token"), 

536 } 

537 ), 

538 "MYSCRIPT_FORCE_STORAGE": force_storage, 

539 } 

540 

541 script_file_path = Path(join(dirname(__file__), "../scripts", "odoo_postupgrade.py")) 

542 final_script = io.StringIO() 

543 with open(script_file_path, encoding="utf-8") as f: 

544 final_script.write(f.read()) 

545 final_script = final_script.getvalue() 

546 

547 with tempfile.NamedTemporaryFile(mode="w+", encoding="utf-8", delete=True) as tmpfile: 

548 tmpfile.write(final_script) 

549 tmpfile.flush() 

550 tmpfile.seek(0) 

551 return self.env["saas.database.task"]._create_k8s_job( 

552 database=self, 

553 script_file_name=Path(tmpfile.name), 

554 script_vars=script_vars, 

555 name_prefix="postupgrade", 

556 mount_filestore=True, 

557 ) 

558 

559 def _run_upgrade_line(self): 

560 """ 

561 Run an upgrade line script on the database 

562 

563 Task kwargs: 

564 :run_info_id: The ID of the run info record 

565 """ 

566 run_info = self.env["saas.upgrade.line.request.run"] 

567 script: str = "" 

568 if self.task_kwargs: 

569 run_info_id = self.task_kwargs.get("run_info_id", None) 

570 script = self.task_kwargs.get("script", "") 

571 run_info = run_info.browse(run_info_id) 

572 

573 if not script: 

574 raise ValidationError(_("The script to run is missing")) 

575 

576 run_info.ensure_one() 

577 request = run_info.request_id 

578 upgrade_line = run_info.upgrade_line_id 

579 ticket = request.ticket_id 

580 request.ensure_one() 

581 upgrade_line.ensure_one() 

582 ticket.ensure_one() 

583 has_new_database = request.upgraded_database_id is not False 

584 callback_url = self._get_upgrade_callback_url() 

585 

586 # Prepare params 

587 params = { 

588 "step_type": "run_script", 

589 "run_info_id": run_info.id, 

590 "request_id": request.id, 

591 "to_version": request.upgrade_type_id.target_odoo_version_group_id.name, 

592 "token": self.env["ir.config_parameter"].sudo().get_param("saas_provider.odoo_project_token"), 

593 } 

594 if has_new_database: 

595 old_database = request.original_database_id 

596 user = self.env.context.get("force_client_user", old_database._get_superuser_login()) 

597 password = old_database._get_client_login_password(user) 

598 params.update( 

599 { 

600 "old_db_user": user, 

601 "old_db_password": password, 

602 "old_client_name": old_database.name, 

603 "old_client_db_name": old_database._get_pg_db_name(), 

604 "from_version": old_database.odoo_version_group_id.name, 

605 } 

606 ) 

607 else: 

608 params.update( 

609 { 

610 "from_version": self.odoo_version_group_id.name, 

611 } 

612 ) 

613 

614 # Get existing config lines for the request and upgrade line, to pass them to the script 

615 existing_config_lines = self.env["saas.upgrade.client.config.line"].search( 

616 [ 

617 ("upgrade_line_id.id", "=", upgrade_line.id), 

618 ("ticket_id.id", "=", ticket.id), 

619 ] 

620 ) 

621 script_vars = { 

622 "MYSCRIPT_HOOK_URL": callback_url, 

623 "MYSCRIPT_HOOK_PARAMS": json.dumps(params), 

624 "CONFIG_LINES": existing_config_lines.to_json(), 

625 } 

626 

627 # Open the script template 

628 script_file_path = Path(join(dirname(__file__), "../scripts", "upgrade_script.py")) 

629 with open(script_file_path, encoding="utf-8") as f: 

630 template_content = f.read() 

631 

632 # Get the script and prepare it 

633 lines = script.strip().splitlines() 

634 if not lines: 

635 indented_script = "" 

636 else: 

637 # First line inherits placeholder's indentation, rest need 4 spaces added 

638 first_line = lines[0] 

639 rest_lines = [" " * 4 + line for line in lines[1:]] 

640 indented_script = "\n".join([first_line] + rest_lines) 

641 

642 final_script = template_content.replace("### [PLACEHOLDER UpgradeScript] ###", indented_script) 

643 timestamp = int(time.time()) 

644 name_prefix = "request-%s-ul-%s-runinfo-%s-%s" % ( 

645 run_info.request_id.id, 

646 run_info.upgrade_line_id.id, 

647 run_info.id, 

648 timestamp, 

649 ) 

650 with tempfile.NamedTemporaryFile(mode="w+", encoding="utf-8", delete=True) as tmpfile: 

651 tmpfile.write(final_script) 

652 tmpfile.flush() 

653 tmpfile.seek(0) 

654 return self.env["saas.database.task"]._create_k8s_job( 

655 database=self, 

656 script_file_name=Path(tmpfile.name), 

657 script_vars=script_vars, 

658 name_prefix=name_prefix, 

659 mount_filestore=True, 

660 job_type="odoo", 

661 ) 

662 

663 def _assign_snapshot_to_request(self): 

664 """ 

665 Assign the created snapshot to the upgrade request, so it can be used in the upgrade process. 

666 The snapshot is assigned to the `prod_backup_id` field if the database is production, 

667 and to the `int_backup_id` in other cases. 

668 

669 Task kwargs: 

670 :helpdesk_ticket_upgrade_request_id: The ID of the helpdesk ticket upgrade request 

671 """ 

672 request = None 

673 if self.task_kwargs: 

674 request_id = self.task_kwargs.get("helpdesk_ticket_upgrade_request_id", None) 

675 request = self.env["helpdesk.ticket.upgrade.request"].browse(request_id).exists() 

676 

677 request.ensure_one() 

678 snapshot = self.env["saas.cnpg.snapshot"].search( 

679 [ 

680 ("database_id", "=", self.id), 

681 ], 

682 order="create_date desc", 

683 limit=1, 

684 ) 

685 if self.database_type_id.is_production: 

686 request.prod_backup_id = snapshot 

687 else: 

688 request.int_backup_id = snapshot 

689 

690 def _duplicate_new(self): 

691 """ 

692 Duplicate the upgraded database to a new database 

693 

694 Task kwargs: 

695 :helpdesk_ticket_upgrade_request_id: The ID of the helpdesk ticket upgrade request 

696 """ 

697 request = None 

698 if self.task_kwargs: 

699 request_id = self.task_kwargs.get("helpdesk_ticket_upgrade_request_id", None) 

700 request = self.env["helpdesk.ticket.upgrade.request"].browse(request_id).exists() 

701 

702 request.ensure_one() 

703 try: 

704 request.upgraded_database_id._action_duplicate_as_prod(request, callback=self.task_kwargs.get("callback")) 

705 except Exception as e: 

706 _logger.error("Error duplicating upgraded database: %s", e) 

707 request._set_error_and_notify(self.env._("Error duplicating upgraded database: %s") % e) 

708 

709 def _final_setup_request(self): 

710 """ 

711 Finalize the upgrade by dropping the temporary upgraded database and assigning the new production database 

712 to the upgrade request and its associated ticket. 

713 

714 Task kwargs: 

715 :helpdesk_ticket_upgrade_request_id: The ID of the helpdesk ticket upgrade request 

716 """ 

717 request = None 

718 if self.task_kwargs: 

719 request_id = self.task_kwargs.get("helpdesk_ticket_upgrade_request_id", None) 

720 request = self.env["helpdesk.ticket.upgrade.request"].browse(request_id).exists() 

721 request.ensure_one() 

722 request.upgraded_database_id._schedule_task( 

723 "drop_drop", state_on_success="deleted" 

724 ) # delete new database if the final setup goes well 

725 request.upgraded_database_id = self 

726 request.ticket_id._compute_database() 

727 

728 def _chain_scripts_execution(self): 

729 """ 

730 Chain the execution of upgrade scripts 

731 

732 Task kwargs: 

733 :helpdesk_ticket_upgrade_request_id: The ID of the helpdesk ticket upgrade request 

734 :callback: The callback method to call after chaining scripts 

735 """ 

736 request = None 

737 if self.task_kwargs: 

738 request_id = self.task_kwargs.get("helpdesk_ticket_upgrade_request_id", None) 

739 request = self.env["helpdesk.ticket.upgrade.request"].browse(request_id).exists() 

740 callback = self.task_kwargs.get("callback", "_next_state") 

741 

742 request.ensure_one() 

743 request._chain_scripts_execution(request.script_ids, callback=callback) 

744 

745 def _notify_rollback(self): 

746 """ 

747 Notify about the production rollback. 

748 

749 Task kwargs: 

750 :helpdesk_ticket_upgrade_request_id: The ID of the helpdesk ticket upgrade request 

751 """ 

752 request: UpgradeRequest = None 

753 if self.task_kwargs: 

754 request_id = self.task_kwargs.get("helpdesk_ticket_upgrade_request_id", None) 

755 request = self.env["helpdesk.ticket.upgrade.request"].browse(request_id).exists() 

756 

757 request.ensure_one() 

758 request.state = "draft" 

759 request.action_cancel() 

760 

761 # Prepare the notification message for the upgrade team 

762 body = self.env._( 

763 "Rollback was performed for this upgrade due to issues detected in it.<br/>- Production database: %s (ID: %s)" 

764 ) 

765 request_info = request._get_request_info() 

766 if request_info.get("integration_ids", []): 

767 body += self.env._("<br/>- Remember to reactivate the integrations linked to this request.") 

768 

769 # Send notification to the customer using the configured mail template. 

770 rollback_template_id = ( 

771 self.env["ir.config_parameter"].sudo().get_param("saas_provider_upgrade.rollback_mail_template_id") 

772 ) 

773 if rollback_template_id: 

774 template = self.env["mail.template"].browse(int(rollback_template_id)) # Send notification to the client 

775 request.ticket_id.message_post_with_source( 

776 template, message_type="comment", subtype_xmlid="mail.mt_comment" 

777 ) 

778 else: 

779 body += self.env._("<br/>- No mail template configured for rollback notification.") 

780 

781 request.ticket_id.message_post( 

782 body=body, 

783 subtype_xmlid="mail.mt_note", 

784 )