"""Function to perform migration of app data to binds.""" import subprocess import pathlib import shutil import logging from pydantic import BaseModel from selfprivacy_api.jobs import Job, JobStatus, Jobs from selfprivacy_api.services.bitwarden import Bitwarden from selfprivacy_api.services.forgejo import Forgejo from selfprivacy_api.services.mailserver import MailServer from selfprivacy_api.services.nextcloud import Nextcloud from selfprivacy_api.services.pleroma import Pleroma from selfprivacy_api.utils import ReadUserData, WriteUserData from selfprivacy_api.utils.huey import huey from selfprivacy_api.utils.block_devices import BlockDevices logger = logging.getLogger(__name__) class BindMigrationConfig(BaseModel): """Config for bind migration. For each service provide block device name. """ email_block_device: str bitwarden_block_device: str gitea_block_device: str nextcloud_block_device: str pleroma_block_device: str def is_bind_migrated() -> bool: """Check if bind migration was performed.""" with ReadUserData() as user_data: return user_data.get("useBinds", False) def activate_binds(config: BindMigrationConfig): """Activate binds.""" # Activate binds in userdata with WriteUserData() as user_data: if "email" not in user_data: user_data["email"] = {} user_data["email"]["location"] = config.email_block_device if "bitwarden" not in user_data: user_data["bitwarden"] = {} user_data["bitwarden"]["location"] = config.bitwarden_block_device if "gitea" not in user_data: user_data["gitea"] = {} user_data["gitea"]["location"] = config.gitea_block_device if "nextcloud" not in user_data: user_data["nextcloud"] = {} user_data["nextcloud"]["location"] = config.nextcloud_block_device if "pleroma" not in user_data: user_data["pleroma"] = {} user_data["pleroma"]["location"] = config.pleroma_block_device user_data["useBinds"] = True def move_folder( data_path: pathlib.Path, bind_path: pathlib.Path, user: str, group: str ): """Move folder from data to bind.""" if data_path.exists(): shutil.move(str(data_path), str(bind_path)) else: return try: data_path.mkdir(mode=0o750, parents=True, exist_ok=True) except Exception as error: logging.error(f"Error creating data path: {error}") return try: shutil.chown(str(bind_path), user=user, group=group) shutil.chown(str(data_path), user=user, group=group) except LookupError: pass try: subprocess.run(["mount", "--bind", str(bind_path), str(data_path)], check=True) except subprocess.CalledProcessError as error: logging.error(error) try: subprocess.run(["chown", "-R", f"{user}:{group}", str(data_path)], check=True) except subprocess.CalledProcessError as error: logging.error(error) @huey.task() def migrate_to_binds(config: BindMigrationConfig, job: Job): """Migrate app data to binds.""" # Exit if migration is already done if is_bind_migrated(): Jobs.update( job=job, status=JobStatus.ERROR, error="Migration already done.", ) return Jobs.update( job=job, status=JobStatus.RUNNING, progress=0, status_text="Checking if all volumes are available.", ) # Get block devices. block_devices = BlockDevices().get_block_devices() block_device_names = [device.name for device in block_devices] # Get all unique required block devices required_block_devices = [] for block_device_name in config.__dict__.values(): if block_device_name not in required_block_devices: required_block_devices.append(block_device_name) # Check if all block devices from config are present. for block_device_name in required_block_devices: if block_device_name not in block_device_names: Jobs.update( job=job, status=JobStatus.ERROR, error=f"Block device {block_device_name} not found.", ) return # Make sure all required block devices are mounted. # sda1 is the root partition and is always mounted. for block_device_name in required_block_devices: if block_device_name == "sda1": continue block_device = BlockDevices().get_block_device(block_device_name) if block_device is None: Jobs.update( job=job, status=JobStatus.ERROR, error=f"Block device {block_device_name} not found.", ) return if f"/volumes/{block_device_name}" not in block_device.mountpoints: Jobs.update( job=job, status=JobStatus.ERROR, error=f"Block device {block_device_name} not mounted.", ) return # Make sure /volumes/sda1 exists. pathlib.Path("/volumes/sda1").mkdir(parents=True, exist_ok=True) Jobs.update( job=job, status=JobStatus.RUNNING, progress=5, status_text="Activating binds in NixOS config.", ) activate_binds(config) # Perform migration of Nextcloud. Jobs.update( job=job, status=JobStatus.RUNNING, progress=10, status_text="Migrating Nextcloud.", ) Nextcloud().stop() # If /volumes/sda1/nextcloud or /volumes/sdb/nextcloud exists, skip it. if not pathlib.Path("/volumes/sda1/nextcloud").exists(): if not pathlib.Path("/volumes/sdb/nextcloud").exists(): move_folder( data_path=pathlib.Path("/var/lib/nextcloud"), bind_path=pathlib.Path( f"/volumes/{config.nextcloud_block_device}/nextcloud" ), user="nextcloud", group="nextcloud", ) # Start Nextcloud Nextcloud().start() # Perform migration of Bitwarden Jobs.update( job=job, status=JobStatus.RUNNING, progress=28, status_text="Migrating Bitwarden.", ) Bitwarden().stop() if not pathlib.Path("/volumes/sda1/bitwarden").exists(): if not pathlib.Path("/volumes/sdb/bitwarden").exists(): move_folder( data_path=pathlib.Path("/var/lib/bitwarden"), bind_path=pathlib.Path( f"/volumes/{config.bitwarden_block_device}/bitwarden" ), user="vaultwarden", group="vaultwarden", ) if not pathlib.Path("/volumes/sda1/bitwarden_rs").exists(): if not pathlib.Path("/volumes/sdb/bitwarden_rs").exists(): move_folder( data_path=pathlib.Path("/var/lib/bitwarden_rs"), bind_path=pathlib.Path( f"/volumes/{config.bitwarden_block_device}/bitwarden_rs" ), user="vaultwarden", group="vaultwarden", ) # Start Bitwarden Bitwarden().start() # Perform migration of Gitea Jobs.update( job=job, status=JobStatus.RUNNING, progress=46, status_text="Migrating Gitea.", ) Forgejo().stop() if not pathlib.Path("/volumes/sda1/gitea").exists(): if not pathlib.Path("/volumes/sdb/gitea").exists(): move_folder( data_path=pathlib.Path("/var/lib/gitea"), bind_path=pathlib.Path(f"/volumes/{config.gitea_block_device}/gitea"), user="gitea", group="gitea", ) Forgejo().start() # Perform migration of Mail server Jobs.update( job=job, status=JobStatus.RUNNING, progress=64, status_text="Migrating Mail server.", ) MailServer().stop() if not pathlib.Path("/volumes/sda1/vmail").exists(): if not pathlib.Path("/volumes/sdb/vmail").exists(): move_folder( data_path=pathlib.Path("/var/vmail"), bind_path=pathlib.Path(f"/volumes/{config.email_block_device}/vmail"), user="virtualMail", group="virtualMail", ) if not pathlib.Path("/volumes/sda1/sieve").exists(): if not pathlib.Path("/volumes/sdb/sieve").exists(): move_folder( data_path=pathlib.Path("/var/sieve"), bind_path=pathlib.Path(f"/volumes/{config.email_block_device}/sieve"), user="virtualMail", group="virtualMail", ) MailServer().start() # Perform migration of Pleroma Jobs.update( job=job, status=JobStatus.RUNNING, progress=82, status_text="Migrating Pleroma.", ) Pleroma().stop() if not pathlib.Path("/volumes/sda1/pleroma").exists(): if not pathlib.Path("/volumes/sdb/pleroma").exists(): move_folder( data_path=pathlib.Path("/var/lib/pleroma"), bind_path=pathlib.Path( f"/volumes/{config.pleroma_block_device}/pleroma" ), user="pleroma", group="pleroma", ) if not pathlib.Path("/volumes/sda1/postgresql").exists(): if not pathlib.Path("/volumes/sdb/postgresql").exists(): move_folder( data_path=pathlib.Path("/var/lib/postgresql"), bind_path=pathlib.Path( f"/volumes/{config.pleroma_block_device}/postgresql" ), user="postgres", group="postgres", ) Pleroma().start() Jobs.update( job=job, status=JobStatus.FINISHED, progress=100, status_text="Migration finished.", result="Migration finished.", ) def start_bind_migration(config: BindMigrationConfig) -> Job: """Start migration.""" job = Jobs.add( type_id="migrations.migrate_to_binds", name="Migrate to binds", description="Migration required to use the new disk space management.", ) migrate_to_binds(config, job) return job