Compare commits

..

No commits in common. "5f3afa6da250d5ed1ccd296cba39f255190e5276" and "74ee584fface3213937431516a6397e3aca51cc3" have entirely different histories.

4 changed files with 28 additions and 235 deletions

View file

@ -1,39 +0,0 @@
-- © 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'
);

View file

@ -689,53 +689,6 @@ 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.
@ -775,7 +728,6 @@ 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()

View file

@ -2523,15 +2523,6 @@ 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",

View file

@ -186,18 +186,11 @@ 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()
@ -211,17 +204,6 @@ 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)),
@ -231,54 +213,6 @@ 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.
@ -982,8 +916,20 @@ 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 :")
self.print_uninstall_reason(lst_uninstall_reason)
for name, reason, origin in lst_uninstall_reason:
print(
f" - {name}"
+ (
f" — {reason}"
if reason
else " — ⚠️ no reason given"
)
+ f" [{origin}]"
)
if config_state_1_uninstall_module:
lst_module_to_uninstall = (
@ -1223,24 +1169,9 @@ class TodoUpgrade:
)
if not lst_module_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)
)
lst_module_to_uninstall = config_state_4_uninstall_module[
index
]
if lst_module_to_uninstall:
self.uninstall_from_database(
@ -1691,33 +1622,15 @@ class TodoUpgrade:
if not lst_fix_migration_odoo[index]:
print("")
stem = os.path.join(
PATH_MIGRATION_GLOBAL,
f"fix_migration_odoo{(next_version - 1) * 10}"
f"_to_odoo{next_version * 10}",
file_path_fix_migration = os.path.join(
"script",
"odoo",
"migration",
f"fix_migration_odoo{(next_version-1)*10}_to_odoo{next_version*10}.py",
)
# 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:
if os.path.exists(file_path_fix_migration):
status, cmd_executed = self.todo_upgrade_execute(
cmd_fix_migration,
f"cat ./{file_path_fix_migration} | ./odoo{next_version}.0/odoo/odoo-bin shell -d {database_name_upgrade}",
single_source_odoo=True,
)
@ -1804,17 +1717,11 @@ 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
@ -1832,15 +1739,11 @@ class TodoUpgrade:
)
self.write_config()
print(
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."
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}'."
)
return
@ -2125,20 +2028,6 @@ 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.