From 76cfe9657f8d8cb61a461b32e18f399cbfb88d77 Mon Sep 17 00:00:00 2001 From: augustin64 Date: Sun, 19 Nov 2023 15:52:48 +0100 Subject: [PATCH] Add migration tool --- .gitignore | 3 + requirements.txt | 3 +- scripts/hooks/utils.py | 9 +++ scripts/hooks/v1.py | 44 +++++++++++ scripts/migration.py | 165 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 223 insertions(+), 1 deletion(-) create mode 100644 scripts/hooks/utils.py create mode 100644 scripts/hooks/v1.py create mode 100644 scripts/migration.py diff --git a/.gitignore b/.gitignore index e53c43c..8026c87 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,6 @@ partitioncloud/search-partitions partitioncloud/static/thumbnails partitioncloud/static/search-thumbnails partitioncloud/attachments + +.venv +backups \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 9be3f1e..28b08ec 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ flask -google \ No newline at end of file +google +colorama \ No newline at end of file diff --git a/scripts/hooks/utils.py b/scripts/hooks/utils.py new file mode 100644 index 0000000..1ef91dc --- /dev/null +++ b/scripts/hooks/utils.py @@ -0,0 +1,9 @@ +import sqlite3 + +def run_sqlite_command(cmd): + """Run a command against the database""" + con = sqlite3.connect("instance/partitioncloud.db") + cur = con.cursor() + cur.execute(cmd) + con.commit() + con.close() \ No newline at end of file diff --git a/scripts/hooks/v1.py b/scripts/hooks/v1.py new file mode 100644 index 0000000..887a18e --- /dev/null +++ b/scripts/hooks/v1.py @@ -0,0 +1,44 @@ +import os +from hooks import utils + +def add_source(): + utils.run_sqlite_command( + "ALTER TABLE partition ADD source TEXT DEFAULT 'unknown'" + ) + +def add_groupes(): + utils.run_sqlite_command( + """CREATE TABLE groupe ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + uuid TEXT(36) NOT NULL + ); + + CREATE TABLE groupe_contient_user ( + groupe_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (groupe_id, user_id) + ); + + CREATE TABLE groupe_contient_album ( + groupe_id INTEGER NOT NULL, + album_id INTEGER NOT NULL, + PRIMARY KEY (groupe_id, album_id) + );""" + ) + +def add_attachments(): + os.makedirs("partitioncloud/attachments", exist_ok=True) + utils.run_sqlite_command( + """CREATE TABLE attachments ( + uuid TEXT(36) PRIMARY KEY, + name TEXT NOT NULL, + filetype TEXT NOT NULL DEFAULT 'mp3', + partition_uuid INTEGER NOT NULL, + user_id INTEGER NOT NULL + );""" + ) + +def install_colorama(): + os.system("pip install colorama -qq") \ No newline at end of file diff --git a/scripts/migration.py b/scripts/migration.py new file mode 100644 index 0000000..bb9928a --- /dev/null +++ b/scripts/migration.py @@ -0,0 +1,165 @@ +#!/usr/bin/python3 +import os +import shutil +import argparse +from functools import cmp_to_key +from distutils.dir_util import copy_tree + +from colorama import Fore, Style +from hooks import v1 + + +def get_version(v: str) -> (int, int, int): + """Returns a tuple (major, minor, patch from the string v{major}.{minor}.{patch})""" + assert (v[0] == 'v') # Check if the version is correctly formatted + return tuple(map(int, v[1:].split('.'))) + + +def is_newer(v1: str, v2: str) -> bool: + """Returns True if v1 > v2""" + return get_version(v1) > get_version(v2) + + +hooks = [ + ("v1.3.0", [ + ("add SOURCE column", v1.add_source) + ]), + ("v1.2.0", [ + ("create groupe structure", v1.add_groupes) + ]), + ("v1.3.0", [ + ("create attachment table", v1.add_attachments) + ]), + ("v1.3.3", [ + ("Install colorama", v1.install_colorama) + ]) +] + + +def get_hooks(current, target): + """Returns a list of hooks needed to migrate""" + def compare(v1: str, v2: str): + if is_newer(v2[0], v1[0]): + return -1 + elif is_newer(v1[0], v2[0]): + return 1 + else: + return 0 + + applied_hooks = [] + for hook in hooks: + if is_newer(hook[0], current) and (target == hook[0] or is_newer(target, hook[0])): + applied_hooks.append(hook) + + return sorted(applied_hooks, key=cmp_to_key(compare)) + + +def backup_instance(version, verbose=True): + """Backs up current instance in backups/{version}""" + def print_verbose(*args): + if verbose: + print(*args) + + print_verbose("Backing up current instance") + dest = os.path.join("backups", version) + + if os.path.exists(dest): + print(f"{Fore.RED}Backup directory already exists{Style.RESET_ALL}") + exit(1) + + os.makedirs(dest) + paths = [ + ("instance", os.path.join(dest, "instance")), + (os.path.join("partitioncloud", "partitions"), os.path.join(dest, "partitions")), + (os.path.join("partitioncloud", "attachments"), os.path.join(dest, "attachments")), + (os.path.join("partitioncloud", "search-partitions"), os.path.join(dest, "search-partitions")) + ] + for src, dst in paths: + if os.path.exists(src): + print_verbose(f"\tBacking up {src}") + copy_tree(src, dst) + + +def print_hooks(hooks): + for hook in hooks: + print(f"- {Fore.BLUE}{hook[0]}{Style.RESET_ALL}:") + for subhook in hook[1]: + print("\t", subhook[0]) + + +def apply_hooks(hooks): + for hook in hooks: + print(f"Migrating to {Fore.BLUE}{hook[0]}{Style.RESET_ALL}:") + for subhook in hook[1]: + print(f"\tApplying '{subhook[0]}'") + subhook[1]() + + +def migrate(current, target, skip_backup=False): + """""" + print(f"Trying to migrate from {args.current} to {args.target}") + + assert is_newer(args.target, args.current) + + applied_hooks = get_hooks(args.current, args.target) + if (len(applied_hooks) == 0): + print(f"{Fore.GREEN}No hook to apply{Style.RESET_ALL}") + exit(0) + + print("The following hooks will be applied:") + print_hooks(applied_hooks) + + if input("Apply these hooks ? [y/N] ") != "y": + print(f"{Fore.RED}Aborting !{Style.RESET_ALL}") + exit(1) + + if not skip_backup: + backup_instance(current) + print(f"Instance backed up in {Style.BRIGHT}backups/{current}{Style.RESET_ALL}") + else: + print("Skipping automatic backup") + + apply_hooks(applied_hooks) + print("Done !") + + +def restore(version): + if input("Do you really want to restore from backup ? Your current data will be deleted [y/N] ") != "y": + print(f"{Fore.RED}Aborting !{Style.RESET_ALL}") + exit(1) + + dest = os.path.join("backups", version) + print(f"Restoring from {dst}") + paths = [ + ("instance", os.path.join(dest, "instance")), + (os.path.join("partitioncloud", "partitions"), os.path.join(dest, "partitions")), + (os.path.join("partitioncloud", "attachments"), os.path.join(dest, "attachments")), + (os.path.join("partitioncloud", "search-partitions"), os.path.join(dest, "search-partitions")) + ] + for src, dst in paths: + if os.path.exists(src): + shutil.rmtree(src) + + if os.path.exists(dst): + print(f"\tRestoring {src}") + copy_tree(dst, src) + else: + print(f"{Fore.RED}No available backup for {src}, deleting current content to avoid any conflict{Style.RESET_ALL}") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + prog='PartitionCloud Migration tool', + description='Helps you migrate from one version to another') + + parser.add_argument('-c', '--current', help="current version (vx.y.z)") + parser.add_argument('-t', '--target', help="target version (vx.y.z)") + parser.add_argument('-s', '--skip-backup', action='store_true') + parser.add_argument('-r', '--restore', help='restore from specific version backup, will not apply any hook (vx.y.z)') + + args = parser.parse_args() + + if args.restore is None: + migrate(args.current, args.target, skip_backup=args.skip_backup) + else: + restore(args.restore) \ No newline at end of file