Compare commits
3 commits
74ee584ffa
...
5f3afa6da2
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f3afa6da2 | |||
| 11d9b88de9 | |||
| 1f0b5c021f |
4 changed files with 235 additions and 28 deletions
39
script/odoo/migration/fix_migration_odoo130_to_odoo140.sql
Normal file
39
script/odoo/migration/fix_migration_odoo130_to_odoo140.sql
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
-- © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
||||
-- License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||
--
|
||||
-- Odoo 13.0 -> 14.0 : hand the « group_fiscal_year » security group over to
|
||||
-- the module that owns it in 14.0.
|
||||
--
|
||||
-- In 13.0 the group « Allow to define fiscal years of more or less than a
|
||||
-- year » is declared by the core « account » module, so the database holds it
|
||||
-- as account.group_fiscal_year. In 14.0 core account no longer declares it and
|
||||
-- om_account_accountant (odoomates) does. During the upgrade that module finds
|
||||
-- no XML id of its own, tries to CREATE the group, and hits:
|
||||
--
|
||||
-- duplicate key value violates unique constraint "res_groups_name_uniq"
|
||||
-- Key (category_id, name)=(9, Allow to define fiscal years ...) already exists
|
||||
--
|
||||
-- Renaming the XML id makes Odoo UPDATE the existing row instead of creating a
|
||||
-- duplicate. The record id is untouched, so any user assignment, access right
|
||||
-- or record rule pointing at the group survives.
|
||||
--
|
||||
-- Runs through psql, not the Odoo shell: at this point the database is still
|
||||
-- 13.0 and loading it with the 14.0 registry is exactly what fails.
|
||||
|
||||
UPDATE ir_model_data
|
||||
SET module = 'om_account_accountant'
|
||||
WHERE model = 'res.groups'
|
||||
AND module = 'account'
|
||||
AND name = 'group_fiscal_year'
|
||||
-- Only when that module is actually part of this database.
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM ir_module_module
|
||||
WHERE name = 'om_account_accountant'
|
||||
AND state IN ('installed', 'to upgrade', 'to install')
|
||||
)
|
||||
-- Idempotent: do nothing if the target XML id already exists.
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM ir_model_data
|
||||
WHERE module = 'om_account_accountant'
|
||||
AND name = 'group_fiscal_year'
|
||||
);
|
||||
|
|
@ -689,6 +689,53 @@ def kernel_modules_stale() -> str:
|
|||
)
|
||||
|
||||
|
||||
def ensure_ssh_key(runner: Runner) -> None:
|
||||
"""Garantit que l'utilisateur possède une clé publique SSH.
|
||||
|
||||
Sans clé, cloud-init ne peut en injecter aucune dans la VM créée : elle
|
||||
démarre sans accès SSH et l'orchestrateur ne peut plus vérifier son état.
|
||||
On en génère donc une (ed25519, sans passphrase) quand il n'y en a pas.
|
||||
|
||||
La clé doit appartenir à l'UTILISATEUR, pas à root : sous sudo, « ~ »
|
||||
désigne /root et la clé y serait inutilisable.
|
||||
"""
|
||||
user = invoking_user()
|
||||
home = os.path.expanduser(f"~{user}")
|
||||
ssh_dir = os.path.join(home, ".ssh")
|
||||
for name in ("id_ed25519.pub", "id_rsa.pub"):
|
||||
path_pub = os.path.join(ssh_dir, name)
|
||||
if os.path.exists(path_pub):
|
||||
print(f" Clé SSH déjà présente : {path_pub}")
|
||||
return
|
||||
|
||||
path_key = os.path.join(ssh_dir, "id_ed25519")
|
||||
print(f" Génération d'une clé SSH ed25519 pour « {user} »…")
|
||||
# sudo -u : on exécute EN TANT QUE l'utilisateur, pour que la clé et le
|
||||
# répertoire lui appartiennent même quand --setup-host tourne sous sudo.
|
||||
prefix = (
|
||||
["sudo", "-u", user] if os.geteuid() == 0 and user != "root" else []
|
||||
)
|
||||
runner.run(prefix + ["mkdir", "-p", "-m", "700", ssh_dir], check=False)
|
||||
runner.run(
|
||||
prefix
|
||||
+ [
|
||||
"ssh-keygen",
|
||||
"-t",
|
||||
"ed25519",
|
||||
"-N",
|
||||
"",
|
||||
"-q",
|
||||
"-f",
|
||||
path_key,
|
||||
"-C",
|
||||
f"erplibre-deploy@{socket.gethostname()}",
|
||||
],
|
||||
check=False,
|
||||
)
|
||||
if not runner.dry_run and os.path.exists(f"{path_key}.pub"):
|
||||
print(f" Clé créée : {path_key}.pub")
|
||||
|
||||
|
||||
def schedule_reboot(runner: Runner) -> None:
|
||||
"""Programme un redémarrage DIFFÉRÉ et détaché de la session courante.
|
||||
|
||||
|
|
@ -728,6 +775,7 @@ def setup_host(
|
|||
ensure_tools(runner, assume_yes, no_install, force_daemon=True)
|
||||
ensure_libvirt_service(runner)
|
||||
already_in_group = ensure_libvirt_group(runner)
|
||||
ensure_ssh_key(runner)
|
||||
# Diagnostiqué AVANT le réseau : sans les modules du noyau en cours, le
|
||||
# pont virbr0 est impossible et l'erreur de virsh est indéchiffrable.
|
||||
stale = kernel_modules_stale()
|
||||
|
|
|
|||
|
|
@ -2523,6 +2523,15 @@ TRANSLATIONS = {
|
|||
"fr": "Reprise à partir de l'étape",
|
||||
"en": "Replaying from step",
|
||||
},
|
||||
"Replay the upgrade from version N": {
|
||||
"fr": "Reprendre la montée à partir de la version N",
|
||||
"en": "Replay the upgrade from version N",
|
||||
},
|
||||
"rebuilds the intermediate database": {
|
||||
"fr": "recrée la base intermédiaire",
|
||||
"en": "rebuilds the intermediate database",
|
||||
},
|
||||
"Unknown version": {"fr": "Version inconnue", "en": "Unknown version"},
|
||||
"The progression file is invalid, ignoring it": {
|
||||
"fr": "Le fichier de progression est invalide, on l'ignore",
|
||||
"en": "The progression file is invalid, ignoring it",
|
||||
|
|
|
|||
|
|
@ -186,11 +186,18 @@ class TodoUpgrade:
|
|||
icon, detail = self.step_status(old_dct_progression, step)
|
||||
print(f" [{step}] {icon} {t(label):<44} {detail}")
|
||||
print()
|
||||
lst_version = self.version_bumps(old_dct_progression)
|
||||
print(f" [c] {t('Continue where it stopped')}")
|
||||
print(
|
||||
f" [0-4] {t('Replay from that step')}"
|
||||
f" ({t('erases the progression of that step and the next ones')})"
|
||||
)
|
||||
if lst_version:
|
||||
print(
|
||||
f" [4.N] {t('Replay the upgrade from version N')}"
|
||||
f" ({'/'.join(str(v) for v in lst_version)}) —"
|
||||
f" {t('rebuilds the intermediate database')}"
|
||||
)
|
||||
print(f" [n] {t('New migration, erase everything')}")
|
||||
print(f" [r] {t('Keep the zip only, ask every question again')}")
|
||||
answer = input(f"💬 {t('Your choice')} : ").strip().lower()
|
||||
|
|
@ -204,6 +211,17 @@ class TodoUpgrade:
|
|||
"migration_file": old_dct_progression.get("migration_file"),
|
||||
"date_create": old_dct_progression.get("date_create"),
|
||||
}, True
|
||||
if answer.startswith("4.") and lst_version:
|
||||
target = answer.split(".", 1)[1].strip()
|
||||
if target.isdigit() and int(target) in lst_version:
|
||||
return (
|
||||
self.rewind_version_bump(
|
||||
old_dct_progression, lst_version.index(int(target))
|
||||
),
|
||||
True,
|
||||
)
|
||||
print(f"⚠️ {t('Unknown version')} : {target}")
|
||||
return old_dct_progression, False
|
||||
if answer.isdigit() and 0 <= int(answer) <= MIGRATION_STEP[-1][0]:
|
||||
return (
|
||||
self.rewind_progression(old_dct_progression, int(answer)),
|
||||
|
|
@ -213,6 +231,54 @@ class TodoUpgrade:
|
|||
print(f"⚠️ {t('Unknown choice, continuing where it stopped')}.")
|
||||
return old_dct_progression, False
|
||||
|
||||
@staticmethod
|
||||
def version_bumps(dct_progression):
|
||||
"""Odoo versions the step 4 loop walks through, e.g. [13, 14, ..., 18].
|
||||
|
||||
The per-version lists all end on the target, so the first bump is
|
||||
« target - len + 1 ». Returns [] when step 4 has not started.
|
||||
"""
|
||||
total = max(
|
||||
[
|
||||
len(value)
|
||||
for key, value in dct_progression.items()
|
||||
if key.startswith("state_4_")
|
||||
and key.endswith("_odoo_lst")
|
||||
and isinstance(value, list)
|
||||
]
|
||||
or [0]
|
||||
)
|
||||
if not total:
|
||||
return []
|
||||
try:
|
||||
last = int(float(dct_progression["target_odoo_version"]))
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return []
|
||||
return list(range(last - total + 1, last + 1))
|
||||
|
||||
@staticmethod
|
||||
def rewind_version_bump(old_dct_progression, index):
|
||||
"""Replay the step 4 loop from one version bump onwards.
|
||||
|
||||
Only the per-version lists are trimmed from `index`: earlier bumps stay
|
||||
migrated and steps 0 to 3 are untouched. Resetting the clone entry is
|
||||
the point — the intermediate database of a failed bump is half
|
||||
migrated, so it must be dropped and rebuilt from the previous version
|
||||
rather than upgraded again.
|
||||
"""
|
||||
dct_kept = dict(old_dct_progression)
|
||||
for key, value in old_dct_progression.items():
|
||||
if (
|
||||
key.startswith("state_4_")
|
||||
and isinstance(value, list)
|
||||
and key.endswith(("_odoo_lst", "_module"))
|
||||
):
|
||||
dct_kept[key] = [
|
||||
item if i < index else False
|
||||
for i, item in enumerate(value)
|
||||
]
|
||||
return dct_kept
|
||||
|
||||
@staticmethod
|
||||
def rewind_progression(old_dct_progression, step):
|
||||
"""Drop the progression of `step` and of every later step.
|
||||
|
|
@ -916,20 +982,8 @@ class TodoUpgrade:
|
|||
self.read_uninstall_module_list(start_version, database_name)
|
||||
)
|
||||
if lst_uninstall_reason:
|
||||
# Show WHY each module goes away: which modules must be dropped
|
||||
# depends on the database, and a removal without a stated reason
|
||||
# is a decision nobody can review later.
|
||||
print("✨ Modules to uninstall before migration :")
|
||||
for name, reason, origin in lst_uninstall_reason:
|
||||
print(
|
||||
f" - {name}"
|
||||
+ (
|
||||
f" — {reason}"
|
||||
if reason
|
||||
else " — ⚠️ no reason given"
|
||||
)
|
||||
+ f" [{origin}]"
|
||||
)
|
||||
self.print_uninstall_reason(lst_uninstall_reason)
|
||||
|
||||
if config_state_1_uninstall_module:
|
||||
lst_module_to_uninstall = (
|
||||
|
|
@ -1169,9 +1223,24 @@ class TodoUpgrade:
|
|||
)
|
||||
|
||||
if not lst_module_uninstall_module[index]:
|
||||
lst_module_to_uninstall = config_state_4_uninstall_module[
|
||||
index
|
||||
]
|
||||
lst_module_to_uninstall = (
|
||||
config_state_4_uninstall_module[index] or []
|
||||
)
|
||||
# Same file convention as step 1, one file per version bump:
|
||||
# uninstall_module_list_odoo130_to_odoo140.txt is read HERE,
|
||||
# right before the 13 -> 14 data migration. Without this the
|
||||
# per-bump files existed in name only and were never read.
|
||||
lst_file, lst_detail = self.read_uninstall_module_list(
|
||||
next_version - 1, database_name
|
||||
)
|
||||
if lst_detail:
|
||||
print(
|
||||
f"✨ Modules to uninstall before Odoo{next_version} :"
|
||||
)
|
||||
self.print_uninstall_reason(lst_detail)
|
||||
lst_module_to_uninstall = list(
|
||||
dict.fromkeys(list(lst_module_to_uninstall) + lst_file)
|
||||
)
|
||||
|
||||
if lst_module_to_uninstall:
|
||||
self.uninstall_from_database(
|
||||
|
|
@ -1622,15 +1691,33 @@ class TodoUpgrade:
|
|||
|
||||
if not lst_fix_migration_odoo[index]:
|
||||
print("")
|
||||
file_path_fix_migration = os.path.join(
|
||||
"script",
|
||||
"odoo",
|
||||
"migration",
|
||||
f"fix_migration_odoo{(next_version-1)*10}_to_odoo{next_version*10}.py",
|
||||
stem = os.path.join(
|
||||
PATH_MIGRATION_GLOBAL,
|
||||
f"fix_migration_odoo{(next_version - 1) * 10}"
|
||||
f"_to_odoo{next_version * 10}",
|
||||
)
|
||||
if os.path.exists(file_path_fix_migration):
|
||||
# Two flavours. « .sql » runs through psql: no Odoo registry,
|
||||
# so it works on a database not yet migrated -- exactly when
|
||||
# loading it with the TARGET version's code would fail. « .py »
|
||||
# is piped into the Odoo shell when the ORM is really needed.
|
||||
file_path_fix_migration = ""
|
||||
cmd_fix_migration = ""
|
||||
if os.path.exists(f"{stem}.sql"):
|
||||
file_path_fix_migration = f"{stem}.sql"
|
||||
cmd_fix_migration = (
|
||||
f"psql -v ON_ERROR_STOP=1 -d {database_name_upgrade}"
|
||||
f" -f ./{file_path_fix_migration}"
|
||||
)
|
||||
elif os.path.exists(f"{stem}.py"):
|
||||
file_path_fix_migration = f"{stem}.py"
|
||||
cmd_fix_migration = (
|
||||
f"cat ./{file_path_fix_migration} |"
|
||||
f" ./odoo{next_version}.0/odoo/odoo-bin shell"
|
||||
f" -d {database_name_upgrade}"
|
||||
)
|
||||
if file_path_fix_migration:
|
||||
status, cmd_executed = self.todo_upgrade_execute(
|
||||
f"cat ./{file_path_fix_migration} | ./odoo{next_version}.0/odoo/odoo-bin shell -d {database_name_upgrade}",
|
||||
cmd_fix_migration,
|
||||
single_source_odoo=True,
|
||||
)
|
||||
|
||||
|
|
@ -1717,11 +1804,17 @@ class TodoUpgrade:
|
|||
# forced: their arch is a real customization.
|
||||
self.neutralize_cow_views(database_name_upgrade, next_version)
|
||||
|
||||
# wait_at_error=False on purpose: the generic « [1] to redo the
|
||||
# command » would replay OpenUpgrade on a database it has just
|
||||
# half migrated, which never recovers. The failure is handled
|
||||
# below by dropping the clone flag so the replay REBUILDS the
|
||||
# intermediate database from the previous version.
|
||||
status, cmd_executed = self.todo_upgrade_execute(
|
||||
cmd_upgrade,
|
||||
new_env={
|
||||
"OPENUPGRADE_TARGET_VERSION": f"{next_version}.0"
|
||||
},
|
||||
wait_at_error=False,
|
||||
)
|
||||
|
||||
# This is THE data migration. Recording it as done when it
|
||||
|
|
@ -1739,11 +1832,15 @@ class TodoUpgrade:
|
|||
)
|
||||
self.write_config()
|
||||
print(
|
||||
f"❌ -> Database migration to Odoo{next_version} FAILED"
|
||||
f" (status {status}). Stopping before version"
|
||||
f" {next_version + 1} to avoid migrating a broken"
|
||||
" database. Fix the cause, then relaunch: this version"
|
||||
f" replays from a fresh clone of '{database_name}'."
|
||||
f"\n❌ -> Database migration to Odoo{next_version}"
|
||||
f" FAILED (status {status}).\n"
|
||||
f" '{database_name_upgrade}' is now half migrated:"
|
||||
" replaying the command on it would never recover, so"
|
||||
" it is NOT offered.\n"
|
||||
" The clone step has been reset. Fix the cause, then"
|
||||
" relaunch the migration and answer [c] (continue):"
|
||||
f" '{database_name_upgrade}' will be dropped and"
|
||||
" rebuilt from the previous version before retrying."
|
||||
)
|
||||
return
|
||||
|
||||
|
|
@ -2028,6 +2125,20 @@ class TodoUpgrade:
|
|||
lst_module.append((module_name, reason.strip()))
|
||||
return lst_module
|
||||
|
||||
@staticmethod
|
||||
def print_uninstall_reason(lst_detail):
|
||||
"""Show WHY each module goes away.
|
||||
|
||||
Which modules must be dropped depends on the database, and a removal
|
||||
without a stated reason is a decision nobody can review later.
|
||||
"""
|
||||
for name, reason, origin in lst_detail:
|
||||
print(
|
||||
f" - {name}"
|
||||
+ (f" — {reason}" if reason else " — ⚠️ no reason given")
|
||||
+ f" [{origin}]"
|
||||
)
|
||||
|
||||
def read_uninstall_module_list(self, start_version, database_name):
|
||||
"""Modules to uninstall before migrating start_version -> next.
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue