mirror of
https://github.com/Cog-Creators/Red-DiscordBot.git
synced 2026-05-27 17:16:44 -04:00
Add redbot-setup restore cli command (#6709)
This commit is contained in:
+127
-19
@@ -4,32 +4,140 @@
|
|||||||
Backing Up and Restoring Red
|
Backing Up and Restoring Red
|
||||||
============================
|
============================
|
||||||
|
|
||||||
Red can be backed up and restored to any device as long as it is a supported operating system. See page: :ref:`end-user-guarantees`.
|
Red can be backed up and restored to any system as long as it is a supported per our `end-user-guarantees`.
|
||||||
|
The system it's restored to can be different from the system that was backed up.
|
||||||
|
|
||||||
Backup steps are to be done in order and carefully to avoid any issues.
|
.. note::
|
||||||
|
|
||||||
|
Some 3rd-party cogs may not support all systems that Core Red supports and such cogs may therefore not work,
|
||||||
|
if restored to an unsupported system. This does not affect cogs that do not impose additional restrictions.
|
||||||
|
|
||||||
|
.. contents::
|
||||||
|
:local:
|
||||||
|
:depth: 2
|
||||||
|
|
||||||
|
Creating backups
|
||||||
|
****************
|
||||||
|
|
||||||
|
Windows
|
||||||
|
-------
|
||||||
|
|
||||||
|
To make a backup, perform the following steps:
|
||||||
|
|
||||||
#. Take note of the installed cogs with ``[p]cogs``; and cog repositories with ``[p]load downloader``, then ``[p]repo list`` (``[p]`` is your bot's prefix).
|
|
||||||
#. Stop the bot, ideally with ``[p]shutdown``.
|
#. Stop the bot, ideally with ``[p]shutdown``.
|
||||||
#. Activate your venv, and run ``redbot-setup backup <instancename>``, replacing ``<instancename>`` with the name of your instance.
|
#. Activate your venv.
|
||||||
#. Copy your backup file to the new machine/location.
|
|
||||||
#. Extract the file to a location of your choice (remember the full path and make sure that the user you are going to install/run Red under can access this path).
|
|
||||||
#. :ref:`Install Red <install-guides>` as normal on the new machine/location.
|
|
||||||
#. Run ``redbot-setup`` in your venv to create a new instance, using the path you remembered above as your data path.
|
|
||||||
#. Start your new instance.
|
|
||||||
#. Re-add the cog repositories using the same names as before.
|
|
||||||
#. Do ``[p]cog update``.
|
|
||||||
#. Re-add any cogs that were not re-installed (you may have to uninstall them first as Downloader may think they are still installed).
|
|
||||||
|
|
||||||
.. note::
|
.. prompt:: batch
|
||||||
|
|
||||||
The config (data) from cogs has been saved, but not the code itself.
|
"%userprofile%\redenv\Scripts\activate.bat"
|
||||||
|
#. Backup your Red instance with the following command:
|
||||||
|
|
||||||
.. tip::
|
.. prompt:: batch
|
||||||
|
:prompts: (redenv) C:\\>
|
||||||
|
|
||||||
You can fix permissions (if needed) on your directory using:
|
redbot-setup backup <your instance name>
|
||||||
|
|
||||||
.. code-block:: bash
|
.. attention::
|
||||||
|
|
||||||
sudo chown -R <user>:<user> ~/.local
|
Replace ``<your instance name>`` with the name of the instance you want to backup.
|
||||||
|
#. The command will create a backup file for you and show you the path to it.
|
||||||
|
|
||||||
Replace ``<user>`` with your actual username.
|
.. tip::
|
||||||
|
|
||||||
|
If you want to backup your instance to a custom folder,
|
||||||
|
you can run the ``redbot-setup backup`` command as shown below,
|
||||||
|
replacing ``C:\path\to\backup\folder`` with the path to the folder that
|
||||||
|
you want to backup your instance to:
|
||||||
|
|
||||||
|
.. prompt:: batch
|
||||||
|
:prompts: (redenv) C:\\>
|
||||||
|
|
||||||
|
redbot-setup backup <your instance name> C:\path\to\backup\folder
|
||||||
|
|
||||||
|
Linux & Mac
|
||||||
|
-----------
|
||||||
|
|
||||||
|
To make a backup, perform the following steps:
|
||||||
|
|
||||||
|
#. Stop the bot, ideally with ``[p]shutdown``.
|
||||||
|
#. Activate your venv.
|
||||||
|
|
||||||
|
.. prompt:: bash
|
||||||
|
|
||||||
|
source ~/redenv/bin/activate
|
||||||
|
#. Backup your Red instance with the following command:
|
||||||
|
|
||||||
|
.. prompt:: bash
|
||||||
|
:prompts: (redenv) $
|
||||||
|
|
||||||
|
redbot-setup backup <your instance name>
|
||||||
|
|
||||||
|
.. attention::
|
||||||
|
|
||||||
|
Replace ``<your instance name>`` with the name of the instance you want to backup.
|
||||||
|
#. The command will create a backup file for you and show you the path to it.
|
||||||
|
|
||||||
|
.. tip::
|
||||||
|
|
||||||
|
If you want to backup your instance to a custom folder,
|
||||||
|
you can run the ``redbot-setup backup`` command as shown below,
|
||||||
|
replacing ``/path/to/backup/folder`` with the path to the folder that
|
||||||
|
you want to backup your instance to:
|
||||||
|
|
||||||
|
.. prompt:: bash
|
||||||
|
:prompts: (redenv) $
|
||||||
|
|
||||||
|
redbot-setup backup <your instance name> /path/to/backup/folder
|
||||||
|
|
||||||
|
Restoring backups
|
||||||
|
*****************
|
||||||
|
|
||||||
|
Windows
|
||||||
|
-------
|
||||||
|
|
||||||
|
To restore a backup, perform the following steps:
|
||||||
|
|
||||||
|
#. `Install Red <windows-install-guide>` on the new machine/location, skipping the ``redbot-setup`` step.
|
||||||
|
#. Activate your venv.
|
||||||
|
|
||||||
|
.. prompt:: batch
|
||||||
|
|
||||||
|
"%userprofile%\redenv\Scripts\activate.bat"
|
||||||
|
#. Restore your Red instance with the following command:
|
||||||
|
|
||||||
|
.. prompt:: batch
|
||||||
|
:prompts: (redenv) C:\\>
|
||||||
|
|
||||||
|
redbot-setup restore C:\path\to\backup\file.tar.gz
|
||||||
|
|
||||||
|
.. attention::
|
||||||
|
|
||||||
|
Replace ``C:\path\to\backup\file.tar.gz`` with the path to the backup file
|
||||||
|
that you want to restore from.
|
||||||
|
|
||||||
|
#. The command will guide you through the restore process.
|
||||||
|
|
||||||
|
Linux & Mac
|
||||||
|
-----------
|
||||||
|
|
||||||
|
To restore a backup, perform the following steps:
|
||||||
|
|
||||||
|
#. `Install Red <install-guides>` on the new machine/location, skipping the ``redbot-setup`` step.
|
||||||
|
#. Activate your venv.
|
||||||
|
|
||||||
|
.. prompt:: bash
|
||||||
|
|
||||||
|
source ~/redenv/bin/activate
|
||||||
|
#. Restore your Red instance with the following command:
|
||||||
|
|
||||||
|
.. prompt:: bash
|
||||||
|
:prompts: (redenv) $
|
||||||
|
|
||||||
|
redbot-setup restore /path/to/backup/file.tar.gz
|
||||||
|
|
||||||
|
.. attention::
|
||||||
|
|
||||||
|
Replace ``/path/to/backup/file.tar.gz`` with the path to the backup file
|
||||||
|
that you want to restore from.
|
||||||
|
|
||||||
|
#. The command will guide you through the restore process.
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import contextlib
|
import contextlib
|
||||||
import dataclasses
|
import dataclasses
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
@@ -37,6 +38,7 @@ import discord
|
|||||||
from redbot.core import commands, Config, version_info as red_version_info
|
from redbot.core import commands, Config, version_info as red_version_info
|
||||||
from redbot.core._cog_manager import CogManager
|
from redbot.core._cog_manager import CogManager
|
||||||
from redbot.core.data_manager import cog_data_path
|
from redbot.core.data_manager import cog_data_path
|
||||||
|
from redbot.core.utils._internal_utils import detailed_progress
|
||||||
|
|
||||||
from . import errors
|
from . import errors
|
||||||
from .log import log
|
from .log import log
|
||||||
@@ -864,3 +866,157 @@ class CogUnavailableError(Exception):
|
|||||||
self.repo_name = repo_name
|
self.repo_name = repo_name
|
||||||
self.cog_name = cog_name
|
self.cog_name = cog_name
|
||||||
super().__init__(f"Couldn't find cog {cog_name!r} in {repo_name!r}")
|
super().__init__(f"Couldn't find cog {cog_name!r} in {repo_name!r}")
|
||||||
|
|
||||||
|
|
||||||
|
async def _restore_from_backup() -> None:
|
||||||
|
"""Restore cogs using `repos.json` in cog's data path.
|
||||||
|
|
||||||
|
Used by `redbot-setup restore` cli command.
|
||||||
|
"""
|
||||||
|
with _repo_manager.repos_folder.with_name("repos.json").open(encoding="utf-8") as fp:
|
||||||
|
raw_repos = json.load(fp)
|
||||||
|
|
||||||
|
with detailed_progress(unit="repos") as progress:
|
||||||
|
task_id = progress.add_task("Adding repos")
|
||||||
|
for repo_data in progress.track(raw_repos, task_id=task_id):
|
||||||
|
repo_url = repo_data["url"]
|
||||||
|
repo_name = repo_data["name"]
|
||||||
|
repo_branch = repo_data["branch"]
|
||||||
|
progress.update(task_id, description=f"Adding {repo_name!r} repo")
|
||||||
|
try:
|
||||||
|
await _repo_manager.add_repo(repo_url, repo_name, repo_branch)
|
||||||
|
except errors.ExistingGitRepo:
|
||||||
|
# this should not be possible
|
||||||
|
log.error(
|
||||||
|
"Failed to add repo %r (url: %r branch: %r) as it seems that"
|
||||||
|
" one with this name already exists.",
|
||||||
|
repo_name,
|
||||||
|
repo_url,
|
||||||
|
repo_branch,
|
||||||
|
)
|
||||||
|
except errors.AuthenticationError:
|
||||||
|
log.error(
|
||||||
|
"Failed to add repo %r (url: %r branch: %r) due to authentication failure."
|
||||||
|
" This may also mean that repository no longer exists at that URL.",
|
||||||
|
repo_name,
|
||||||
|
repo_url,
|
||||||
|
repo_branch,
|
||||||
|
)
|
||||||
|
except (errors.CloningError, OSError):
|
||||||
|
log.error(
|
||||||
|
"Failed to add repo %r (url: %r branch: %r) due to a cloning error.",
|
||||||
|
repo_name,
|
||||||
|
repo_url,
|
||||||
|
repo_branch,
|
||||||
|
)
|
||||||
|
|
||||||
|
_installed_cogs = await installed_cogs()
|
||||||
|
await _config.installed_cogs.clear()
|
||||||
|
await _config.installed_libraries.clear()
|
||||||
|
|
||||||
|
cogs_to_reinstall = []
|
||||||
|
cogs_without_repo: Dict[str, List[str]] = defaultdict(list)
|
||||||
|
for cog in _installed_cogs:
|
||||||
|
if cog.repo is None:
|
||||||
|
cogs_without_repo[cog._json_repo_name].append(cog.name)
|
||||||
|
else:
|
||||||
|
cogs_to_reinstall.append(cog)
|
||||||
|
for repo_name, cogs in cogs_without_repo.items():
|
||||||
|
log.error(
|
||||||
|
"The backup does not contain metadata about repo %r,"
|
||||||
|
" the following cogs cannot be reinstalled automatically: %s",
|
||||||
|
repo_name,
|
||||||
|
", ".join(cogs),
|
||||||
|
)
|
||||||
|
|
||||||
|
with detailed_progress(unit="cogs") as progress:
|
||||||
|
task_id = progress.add_task("Installing cogs")
|
||||||
|
cog: Installable
|
||||||
|
for cog in progress.track(cogs_to_reinstall, task_id=task_id):
|
||||||
|
progress.update(task_id, description=f"Installing {cog.name!r} cog")
|
||||||
|
if not cog.commit:
|
||||||
|
last_cog_occurrence = await cog.repo.get_last_module_occurrence(cog.name)
|
||||||
|
if last_cog_occurrence is not None and not last_cog_occurrence.disabled:
|
||||||
|
cog = last_cog_occurrence
|
||||||
|
log.warning(
|
||||||
|
"The commit that %r cog was installed from is unknown"
|
||||||
|
" - will try to reinstall from latest commit where it's still available."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
log.error(
|
||||||
|
"The commit that %r cog was installed from is unknown"
|
||||||
|
" and it could not be found in the repo."
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
install_result = await install_cogs(cog.repo, cog.commit, [cog.name])
|
||||||
|
except errors.UnknownRevision:
|
||||||
|
log.error(
|
||||||
|
"The commit that %r cog was installed from (%s)"
|
||||||
|
" could not be found in the repo.",
|
||||||
|
cog.name,
|
||||||
|
cog.commit,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# we know we've only tried to install one cog so we can be specific in error messages
|
||||||
|
|
||||||
|
# below 3 should technically never happen with valid config but just in case...
|
||||||
|
if install_result.unavailable_cogs:
|
||||||
|
log.error("Could not find %r cog in %r repo", cog.name, cog.repo.name)
|
||||||
|
if install_result.already_installed:
|
||||||
|
log.error(
|
||||||
|
"Failed to reinstall %r cog from %r repo as it seems that"
|
||||||
|
" this cog is already installed.",
|
||||||
|
cog.name,
|
||||||
|
cog.repo.name,
|
||||||
|
)
|
||||||
|
if install_result.name_already_used:
|
||||||
|
log.error(
|
||||||
|
"Failed to reinstall %r cog from %r repo as it seems that"
|
||||||
|
" a cog with the same name is already installed from a different repo.",
|
||||||
|
cog.name,
|
||||||
|
cog.repo.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
if install_result.incompatible_python_version:
|
||||||
|
log.error(
|
||||||
|
"Failed to reinstall %r cog from %r repo because the instance is"
|
||||||
|
" being restored into Red running with a lower Python version."
|
||||||
|
" The minimum Python version required by this cog is %s",
|
||||||
|
cog.name,
|
||||||
|
cog.repo.name,
|
||||||
|
".".join(map(str, cog.min_python_version)),
|
||||||
|
)
|
||||||
|
if install_result.incompatible_bot_version:
|
||||||
|
log.error(
|
||||||
|
"Failed to reinstall %r cog from %r repo because the instance is"
|
||||||
|
" being restored into a Red version that the cog does not support."
|
||||||
|
" The minimum version required by this cog is %s%s.",
|
||||||
|
cog.name,
|
||||||
|
cog.repo.name,
|
||||||
|
cog.min_bot_version,
|
||||||
|
(
|
||||||
|
""
|
||||||
|
if cog.min_bot_version > cog.max_bot_version
|
||||||
|
else f"and maximum allowed is: {cog.max_bot_version}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if install_result.failed_cogs:
|
||||||
|
log.info("Failed to reinstall %r cog", cog.name)
|
||||||
|
if install_result.failed_reqs:
|
||||||
|
log.error(
|
||||||
|
"Failed to reinstall %r cog from %r repo"
|
||||||
|
" because the following requirements could not be reinstalled: %s",
|
||||||
|
cog.name,
|
||||||
|
cog.repo.name,
|
||||||
|
", ".join(install_result.failed_reqs),
|
||||||
|
)
|
||||||
|
if install_result.failed_libs:
|
||||||
|
log.error(
|
||||||
|
"Failed to reinstall shared libraries for %r cog from %r repo: %s",
|
||||||
|
cog.repo.name,
|
||||||
|
", ".join([lib.name for lib in install_result.failed_libs]),
|
||||||
|
)
|
||||||
|
|||||||
@@ -9,9 +9,12 @@ import os
|
|||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import tarfile
|
import tarfile
|
||||||
|
import time
|
||||||
import warnings
|
import warnings
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from io import BytesIO
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from tarfile import TarInfo
|
||||||
from typing import (
|
from typing import (
|
||||||
AsyncIterable,
|
AsyncIterable,
|
||||||
AsyncIterator,
|
AsyncIterator,
|
||||||
@@ -24,6 +27,7 @@ from typing import (
|
|||||||
Optional,
|
Optional,
|
||||||
Union,
|
Union,
|
||||||
TypeVar,
|
TypeVar,
|
||||||
|
TypedDict,
|
||||||
TYPE_CHECKING,
|
TYPE_CHECKING,
|
||||||
Tuple,
|
Tuple,
|
||||||
cast,
|
cast,
|
||||||
@@ -33,8 +37,9 @@ import aiohttp
|
|||||||
import discord
|
import discord
|
||||||
from packaging.requirements import Requirement
|
from packaging.requirements import Requirement
|
||||||
import rapidfuzz
|
import rapidfuzz
|
||||||
from rich.progress import ProgressColumn
|
import rich.progress
|
||||||
from rich.progress_bar import ProgressBar
|
from rich.console import Console
|
||||||
|
from rich.text import Text
|
||||||
from red_commons.logging import VERBOSE, TRACE
|
from red_commons.logging import VERBOSE, TRACE
|
||||||
|
|
||||||
from redbot import VersionInfo
|
from redbot import VersionInfo
|
||||||
@@ -58,6 +63,8 @@ __all__ = (
|
|||||||
"fetch_latest_red_version_info",
|
"fetch_latest_red_version_info",
|
||||||
"deprecated_removed",
|
"deprecated_removed",
|
||||||
"RichIndefiniteBarColumn",
|
"RichIndefiniteBarColumn",
|
||||||
|
"RichSpeedColumn",
|
||||||
|
"detailed_progress",
|
||||||
"cli_level_to_log_level",
|
"cli_level_to_log_level",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -216,7 +223,27 @@ async def format_fuzzy_results(
|
|||||||
return "Perhaps you wanted one of these? " + box("\n".join(lines), lang="vhdl")
|
return "Perhaps you wanted one of these? " + box("\n".join(lines), lang="vhdl")
|
||||||
|
|
||||||
|
|
||||||
|
def _tar_addfile_from_string(tar: tarfile.TarFile, name: str, string: str) -> None:
|
||||||
|
encoded = string.encode("utf-8")
|
||||||
|
fp = BytesIO(encoded)
|
||||||
|
|
||||||
|
# TarInfo needs `mtime` and `size`
|
||||||
|
# https://stackoverflow.com/q/53306000
|
||||||
|
tar_info = tarfile.TarInfo(name)
|
||||||
|
tar_info.mtime = time.time()
|
||||||
|
tar_info.size = len(encoded)
|
||||||
|
|
||||||
|
tar.addfile(tar_info, fp)
|
||||||
|
|
||||||
|
|
||||||
|
class BackupDetails(TypedDict):
|
||||||
|
backup_version: int
|
||||||
|
|
||||||
|
|
||||||
async def create_backup(dest: Path = Path.home()) -> Optional[Path]:
|
async def create_backup(dest: Path = Path.home()) -> Optional[Path]:
|
||||||
|
# version of backup
|
||||||
|
BACKUP_VERSION = 2
|
||||||
|
|
||||||
data_path = Path(data_manager.core_data_path().parent)
|
data_path = Path(data_manager.core_data_path().parent)
|
||||||
if not data_path.exists():
|
if not data_path.exists():
|
||||||
return None
|
return None
|
||||||
@@ -226,13 +253,20 @@ async def create_backup(dest: Path = Path.home()) -> Optional[Path]:
|
|||||||
backup_fpath = dest / f"redv3_{data_manager.instance_name()}_{timestr}.tar.gz"
|
backup_fpath = dest / f"redv3_{data_manager.instance_name()}_{timestr}.tar.gz"
|
||||||
|
|
||||||
to_backup = []
|
to_backup = []
|
||||||
|
# we need trailing separator to not exclude files and folders that only start with these names
|
||||||
exclusions = [
|
exclusions = [
|
||||||
"__pycache__",
|
"__pycache__",
|
||||||
|
# Lavalink will be downloaded on Audio load
|
||||||
"Lavalink.jar",
|
"Lavalink.jar",
|
||||||
os.path.join("Downloader", "lib"),
|
# cogs and repos installed through Downloader can be reinstalled using restore command
|
||||||
os.path.join("CogManager", "cogs"),
|
os.path.join("Downloader", "lib", ""),
|
||||||
os.path.join("RepoManager", "repos"),
|
os.path.join("CogManager", "cogs", ""),
|
||||||
os.path.join("Audio", "logs"),
|
os.path.join("RepoManager", "repos", ""),
|
||||||
|
os.path.join("Audio", "logs", ""),
|
||||||
|
# these files are created during backup so we exclude them from data path backup
|
||||||
|
os.path.join("RepoManager", "repos.json"),
|
||||||
|
"instance.json",
|
||||||
|
"backup_details.json",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Avoiding circular imports
|
# Avoiding circular imports
|
||||||
@@ -243,19 +277,42 @@ async def create_backup(dest: Path = Path.home()) -> Optional[Path]:
|
|||||||
repo_output = []
|
repo_output = []
|
||||||
for repo in repo_mgr.repos:
|
for repo in repo_mgr.repos:
|
||||||
repo_output.append({"url": repo.url, "name": repo.name, "branch": repo.branch})
|
repo_output.append({"url": repo.url, "name": repo.name, "branch": repo.branch})
|
||||||
repos_file = data_path / "cogs" / "RepoManager" / "repos.json"
|
|
||||||
with repos_file.open("w") as fs:
|
|
||||||
json.dump(repo_output, fs, indent=4)
|
|
||||||
instance_file = data_path / "instance.json"
|
|
||||||
with instance_file.open("w") as fs:
|
|
||||||
json.dump({data_manager.instance_name(): data_manager.basic_config}, fs, indent=4)
|
|
||||||
for f in data_path.glob("**/*"):
|
|
||||||
if not any(ex in str(f) for ex in exclusions) and f.is_file():
|
|
||||||
to_backup.append(f)
|
|
||||||
|
|
||||||
with tarfile.open(str(backup_fpath), "w:gz") as tar:
|
with rich.progress.Progress(
|
||||||
for f in to_backup:
|
rich.progress.SpinnerColumn(),
|
||||||
tar.add(str(f), arcname=str(f.relative_to(data_path)), recursive=False)
|
rich.progress.TextColumn("[progress.description]{task.description}"),
|
||||||
|
RichIndefiniteBarColumn(),
|
||||||
|
rich.progress.TextColumn("{task.completed} files processed"),
|
||||||
|
rich.progress.TimeElapsedColumn(),
|
||||||
|
) as progress:
|
||||||
|
for f in progress.track(
|
||||||
|
data_path.glob("**/*"), description="Preparing files for backup..."
|
||||||
|
):
|
||||||
|
if not any(ex in str(f) for ex in exclusions) and f.is_file():
|
||||||
|
to_backup.append(f)
|
||||||
|
|
||||||
|
backup_details: BackupDetails = {
|
||||||
|
"backup_version": BACKUP_VERSION,
|
||||||
|
}
|
||||||
|
|
||||||
|
with tarfile.open(str(backup_fpath), "w:gz", dereference=True) as tar:
|
||||||
|
with detailed_progress(unit="files") as progress:
|
||||||
|
progress_tracker = progress.track(to_backup, description="Compressing data")
|
||||||
|
for f in progress_tracker:
|
||||||
|
tar.add(str(f), arcname=str(f.relative_to(data_path)), recursive=False)
|
||||||
|
|
||||||
|
# add repos backup
|
||||||
|
repos_data = json.dumps(repo_output, indent=4)
|
||||||
|
_tar_addfile_from_string(tar, "cogs/RepoManager/repos.json", repos_data)
|
||||||
|
|
||||||
|
# add instance's original data
|
||||||
|
instance_data = json.dumps(
|
||||||
|
{data_manager.instance_name(): data_manager.basic_config}, indent=4
|
||||||
|
)
|
||||||
|
_tar_addfile_from_string(tar, "instance.json", instance_data)
|
||||||
|
|
||||||
|
# add info about backup version
|
||||||
|
_tar_addfile_from_string(tar, "backup_details.json", json.dumps(backup_details))
|
||||||
return backup_fpath
|
return backup_fpath
|
||||||
|
|
||||||
|
|
||||||
@@ -367,10 +424,10 @@ def deprecated_removed(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class RichIndefiniteBarColumn(ProgressColumn):
|
class RichIndefiniteBarColumn(rich.progress.ProgressColumn):
|
||||||
def render(self, task):
|
def render(self, task: rich.progress.Task) -> rich.progress.ProgressBar:
|
||||||
return ProgressBar(
|
return rich.progress.ProgressBar(
|
||||||
pulse=task.completed < task.total,
|
pulse=task.completed < task.total if task.total is not None else True,
|
||||||
animation_time=task.get_time(),
|
animation_time=task.get_time(),
|
||||||
width=40,
|
width=40,
|
||||||
total=task.total,
|
total=task.total,
|
||||||
@@ -378,6 +435,33 @@ class RichIndefiniteBarColumn(ProgressColumn):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RichSpeedColumn(rich.progress.ProgressColumn):
|
||||||
|
def __init__(self, *, unit: str) -> None:
|
||||||
|
self.unit = unit
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
def render(self, task: rich.progress.Task) -> Text:
|
||||||
|
speed = task.finished_speed or task.speed
|
||||||
|
if speed is None:
|
||||||
|
return Text("?", style="progress.data.speed")
|
||||||
|
return Text(f"{int(speed)} {self.unit}/s", style="progress.data.speed")
|
||||||
|
|
||||||
|
|
||||||
|
def detailed_progress(*, unit: str, console: Optional[Console] = None) -> rich.progress.Progress:
|
||||||
|
return rich.progress.Progress(
|
||||||
|
rich.progress.SpinnerColumn(),
|
||||||
|
rich.progress.TextColumn("[progress.description]{task.description}"),
|
||||||
|
rich.progress.BarColumn(bar_width=None),
|
||||||
|
RichSpeedColumn(unit=unit),
|
||||||
|
rich.progress.TaskProgressColumn(),
|
||||||
|
rich.progress.TextColumn("eta"),
|
||||||
|
rich.progress.TimeRemainingColumn(),
|
||||||
|
rich.progress.TextColumn("elapsed"),
|
||||||
|
rich.progress.TimeElapsedColumn(),
|
||||||
|
console=console,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def cli_level_to_log_level(level: int) -> int:
|
def cli_level_to_log_level(level: int) -> int:
|
||||||
if level == 0:
|
if level == 0:
|
||||||
log_level = logging.INFO
|
log_level = logging.INFO
|
||||||
|
|||||||
+477
-6
@@ -1,27 +1,35 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
from redbot import _early_init
|
from redbot import _early_init
|
||||||
|
|
||||||
# this needs to be called as early as possible
|
# this needs to be called as early as possible
|
||||||
_early_init()
|
_early_init()
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import functools
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
import re
|
import re
|
||||||
|
import tarfile
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, Any, Optional, Union
|
from typing import Any, Dict, IO, List, NoReturn, Optional, Set, Tuple, Union
|
||||||
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
import redbot.logging
|
import redbot.logging
|
||||||
from redbot.core._cli import confirm
|
from redbot.core._cli import confirm
|
||||||
from redbot.core.utils._internal_utils import (
|
from redbot.core.utils._internal_utils import (
|
||||||
|
BackupDetails,
|
||||||
safe_delete,
|
safe_delete,
|
||||||
create_backup as red_create_backup,
|
create_backup as red_create_backup,
|
||||||
cli_level_to_log_level,
|
cli_level_to_log_level,
|
||||||
|
detailed_progress,
|
||||||
)
|
)
|
||||||
from redbot.core import config, data_manager
|
from redbot.core import config, data_manager, _downloader
|
||||||
|
from redbot.core._cog_manager import CogManager
|
||||||
from redbot.core._config import migrate
|
from redbot.core._config import migrate
|
||||||
from redbot.core._cli import ExitCodes, asyncio_run
|
from redbot.core._cli import ExitCodes, asyncio_run
|
||||||
from redbot.core.data_manager import appdir, config_dir, config_file
|
from redbot.core.data_manager import appdir, config_dir, config_file
|
||||||
@@ -58,10 +66,14 @@ def save_config(name, data, remove=False):
|
|||||||
json.dump(_config, fs, indent=4)
|
json.dump(_config, fs, indent=4)
|
||||||
|
|
||||||
|
|
||||||
|
def get_default_data_path(instance_name: str) -> Path:
|
||||||
|
return Path(appdir.user_data_dir) / "data" / instance_name
|
||||||
|
|
||||||
|
|
||||||
def get_data_dir(*, instance_name: str, data_path: Optional[Path], interactive: bool) -> str:
|
def get_data_dir(*, instance_name: str, data_path: Optional[Path], interactive: bool) -> str:
|
||||||
if data_path is not None:
|
if data_path is not None:
|
||||||
return str(data_path.resolve())
|
return str(data_path.resolve())
|
||||||
default_data_path = Path(appdir.user_data_dir) / "data" / instance_name
|
default_data_path = get_default_data_path(instance_name)
|
||||||
if not interactive:
|
if not interactive:
|
||||||
return str(default_data_path.resolve())
|
return str(default_data_path.resolve())
|
||||||
|
|
||||||
@@ -99,7 +111,7 @@ def get_data_dir(*, instance_name: str, data_path: Optional[Path], interactive:
|
|||||||
" You may need to create the directory and set proper permissions"
|
" You may need to create the directory and set proper permissions"
|
||||||
" for it manually before it can be used as the data directory."
|
" for it manually before it can be used as the data directory."
|
||||||
)
|
)
|
||||||
sys.exit(ExitCodes.INVALID_CLI_USAGE)
|
continue
|
||||||
|
|
||||||
print(f"You have chosen {str(data_path)!r} to be your data directory.")
|
print(f"You have chosen {str(data_path)!r} to be your data directory.")
|
||||||
if click.confirm("Please confirm", default=True):
|
if click.confirm("Please confirm", default=True):
|
||||||
@@ -270,12 +282,15 @@ def get_target_backend(backend: str) -> BackendType:
|
|||||||
|
|
||||||
|
|
||||||
async def do_migration(
|
async def do_migration(
|
||||||
current_backend: BackendType, target_backend: BackendType
|
current_backend: BackendType,
|
||||||
|
target_backend: BackendType,
|
||||||
|
new_storage_details: Optional[dict] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
cur_driver_cls = get_driver_class_include_old(current_backend)
|
cur_driver_cls = get_driver_class_include_old(current_backend)
|
||||||
new_driver_cls = get_driver_class(target_backend)
|
new_driver_cls = get_driver_class(target_backend)
|
||||||
cur_storage_details = data_manager.storage_details()
|
cur_storage_details = data_manager.storage_details()
|
||||||
new_storage_details = new_driver_cls.get_config_details()
|
if new_storage_details is None:
|
||||||
|
new_storage_details = new_driver_cls.get_config_details()
|
||||||
|
|
||||||
await cur_driver_cls.initialize(**cur_storage_details)
|
await cur_driver_cls.initialize(**cur_storage_details)
|
||||||
await new_driver_cls.initialize(**new_storage_details)
|
await new_driver_cls.initialize(**new_storage_details)
|
||||||
@@ -372,6 +387,379 @@ async def remove_instance_interaction() -> None:
|
|||||||
await remove_instance(selected, interactive=True)
|
await remove_instance(selected, interactive=True)
|
||||||
|
|
||||||
|
|
||||||
|
def open_file_from_tar(tar: tarfile.TarFile, arcname: str) -> Optional[IO[bytes]]:
|
||||||
|
try:
|
||||||
|
fp = tar.extractfile(arcname)
|
||||||
|
except (KeyError, tarfile.StreamError):
|
||||||
|
return None
|
||||||
|
return fp
|
||||||
|
|
||||||
|
|
||||||
|
class RestoreInfo:
|
||||||
|
STORAGE_BACKENDS = {
|
||||||
|
BackendType.JSON: "JSON",
|
||||||
|
BackendType.POSTGRES: "PostgreSQL",
|
||||||
|
BackendType.MONGOV1: "MongoDB (unavailable)",
|
||||||
|
BackendType.MONGO: "MongoDB (unavailable)",
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
tar: tarfile.TarFile,
|
||||||
|
backup_details: BackupDetails,
|
||||||
|
name: str,
|
||||||
|
data_path: Path,
|
||||||
|
storage_type: BackendType,
|
||||||
|
storage_details: dict,
|
||||||
|
restore_downloader: Optional[bool] = None,
|
||||||
|
):
|
||||||
|
self.tar = tar
|
||||||
|
self.backup_details = backup_details
|
||||||
|
self.backup_version = backup_details["backup_version"]
|
||||||
|
self.name = name
|
||||||
|
self._data_path = data_path
|
||||||
|
self.storage_type = storage_type
|
||||||
|
self.storage_details = storage_details
|
||||||
|
self._restore_downloader: Optional[bool] = restore_downloader
|
||||||
|
self._data_path_ensure_result: Optional[bool] = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_tar(
|
||||||
|
cls, tar: tarfile.TarFile, *, restore_downloader: Optional[bool] = None
|
||||||
|
) -> RestoreInfo:
|
||||||
|
instance_name, raw_data = cls.get_instance_from_backup(tar)
|
||||||
|
backup_details = cls.get_backup_details(tar)
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
tar=tar,
|
||||||
|
backup_details=backup_details,
|
||||||
|
name=instance_name,
|
||||||
|
data_path=Path(raw_data["DATA_PATH"]),
|
||||||
|
storage_type=BackendType(raw_data["STORAGE_TYPE"]),
|
||||||
|
storage_details=raw_data["STORAGE_DETAILS"],
|
||||||
|
restore_downloader=restore_downloader,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_instance_from_backup(tar: tarfile.TarFile) -> Tuple[str, dict]:
|
||||||
|
if (fp := open_file_from_tar(tar, "instance.json")) is None:
|
||||||
|
print("This isn't a valid backup file!")
|
||||||
|
sys.exit(1)
|
||||||
|
with fp:
|
||||||
|
return json.load(fp).popitem()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_backup_details(tar: tarfile.TarFile) -> BackupDetails:
|
||||||
|
if (fp := open_file_from_tar(tar, "backup_details.json")) is None:
|
||||||
|
# backup version 1 doesn't have the details file
|
||||||
|
return {"backup_version": 1}
|
||||||
|
with fp:
|
||||||
|
backup_details = json.load(fp)
|
||||||
|
backup_version = backup_details.get("backup_version")
|
||||||
|
if not isinstance(backup_version, int):
|
||||||
|
print("This does not appear to be a valid backup.")
|
||||||
|
sys.exit(1)
|
||||||
|
if backup_version > 2:
|
||||||
|
print("This backup was created using newer version of Red. Update Red to restore it.")
|
||||||
|
sys.exit(1)
|
||||||
|
return backup_details
|
||||||
|
|
||||||
|
@property
|
||||||
|
def data_path(self) -> Path:
|
||||||
|
return self._data_path
|
||||||
|
|
||||||
|
@data_path.setter
|
||||||
|
def data_path(self, value: Path) -> None:
|
||||||
|
self._data_path_ensure_result = None
|
||||||
|
self._data_path = value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name_used(self) -> bool:
|
||||||
|
return self.name in instance_list
|
||||||
|
|
||||||
|
def ensure_data_path(self) -> bool:
|
||||||
|
if self._data_path_ensure_result is not None:
|
||||||
|
return self._data_path_ensure_result
|
||||||
|
if self.data_path.is_absolute():
|
||||||
|
try:
|
||||||
|
# try making the dir since that's most reliant access check, if path does not exist
|
||||||
|
self.data_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
except OSError:
|
||||||
|
self._data_path_ensure_result = False
|
||||||
|
else:
|
||||||
|
# if path exists, mkdir above is a no-op so we still have to check for write access
|
||||||
|
self._data_path_ensure_result = os.access(self.data_path, os.W_OK)
|
||||||
|
else:
|
||||||
|
# if path is not absolute, it's not valid on the current OS, e.g.
|
||||||
|
# Path('D:\\data').is_absolute() is False on Linux/macOS
|
||||||
|
# Path('/some/path').is_absolute() is False on Windows
|
||||||
|
self._data_path_ensure_result = False
|
||||||
|
return self._data_path_ensure_result
|
||||||
|
|
||||||
|
@property
|
||||||
|
def data_path_not_empty(self) -> bool:
|
||||||
|
if not self.ensure_data_path():
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
return next(self.data_path.glob("*"), None) is not None
|
||||||
|
except OSError:
|
||||||
|
return True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def backend_unavailable(self) -> bool:
|
||||||
|
return self.storage_type in (BackendType.MONGOV1, BackendType.MONGO)
|
||||||
|
|
||||||
|
@functools.cached_property
|
||||||
|
def can_restore_downloader(self) -> bool:
|
||||||
|
return "cogs/RepoManager/repos.json" in self.all_tar_member_names
|
||||||
|
|
||||||
|
@functools.cached_property
|
||||||
|
def restore_downloader(self) -> bool:
|
||||||
|
if self._restore_downloader is not None:
|
||||||
|
return self.can_restore_downloader
|
||||||
|
return self.can_restore_downloader and click.confirm(
|
||||||
|
"Do you want to restore 3rd-party repos and cogs installed through Downloader?",
|
||||||
|
default=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
@functools.cached_property
|
||||||
|
def all_tar_members(self) -> List[tarfile.TarInfo]:
|
||||||
|
return self.tar.getmembers()
|
||||||
|
|
||||||
|
@functools.cached_property
|
||||||
|
def all_tar_member_names(self) -> List[str]:
|
||||||
|
return [tarinfo.name for tarinfo in self.all_tar_members]
|
||||||
|
|
||||||
|
def get_tar_members_to_extract(self) -> List[tarfile.TarInfo]:
|
||||||
|
ignored_members: Set[str] = {"backup_details.json", "instance.json"}
|
||||||
|
if not self.restore_downloader:
|
||||||
|
ignored_members |= {
|
||||||
|
"cogs/RepoManager/repos.json",
|
||||||
|
"cogs/RepoManager/settings.json",
|
||||||
|
"cogs/Downloader/settings.json",
|
||||||
|
}
|
||||||
|
return [member for member in self.all_tar_members if member.name not in ignored_members]
|
||||||
|
|
||||||
|
def print_instance_data(self) -> None:
|
||||||
|
print("\nWhen the instance was backed up, it was using these settings:")
|
||||||
|
print(" Original instance name:", self.name)
|
||||||
|
print(" Original data path:", self.data_path)
|
||||||
|
print(" Original storage backend:", self.STORAGE_BACKENDS[self.storage_type])
|
||||||
|
self.print_storage_details()
|
||||||
|
|
||||||
|
def print_storage_details(self, *, original: bool = True) -> None:
|
||||||
|
if self.storage_type is BackendType.POSTGRES:
|
||||||
|
if original:
|
||||||
|
print(" Original storage details:")
|
||||||
|
else:
|
||||||
|
print(" Storage details:")
|
||||||
|
for key in ("host", "port", "database", "user"):
|
||||||
|
print(f" - DB {key}:", self.storage_details[key])
|
||||||
|
print(" - DB password: ***")
|
||||||
|
|
||||||
|
def ask_for_changes(self, *, interactive: bool) -> None:
|
||||||
|
if interactive:
|
||||||
|
self._ask_for_optional_changes()
|
||||||
|
self._ask_for_required_changes(interactive=interactive)
|
||||||
|
|
||||||
|
def _ask_for_optional_changes(self) -> None:
|
||||||
|
if click.confirm("\nWould you like to change anything?"):
|
||||||
|
if not self.name_used and click.confirm("Do you want to use different instance name?"):
|
||||||
|
self._ask_for_name()
|
||||||
|
if not self.data_path_not_empty and click.confirm(
|
||||||
|
"Do you want to use different data path?"
|
||||||
|
):
|
||||||
|
self._ask_for_data_path()
|
||||||
|
if not self.backend_unavailable and click.confirm(
|
||||||
|
"Do you want to use different storage backend or change storage details?"
|
||||||
|
):
|
||||||
|
self._ask_for_storage()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _error_and_exit(message: str) -> NoReturn:
|
||||||
|
print(f"ERROR: {message}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _warning(message: str) -> None:
|
||||||
|
print(f"WARNING: {message}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _info(message: str) -> None:
|
||||||
|
print(f"INFO: {message}")
|
||||||
|
|
||||||
|
def _ask_for_required_changes(self, interactive: bool) -> None:
|
||||||
|
p = self._warning if interactive else self._error_and_exit
|
||||||
|
if self.name_used:
|
||||||
|
p("Original instance name is already used by a different instance.")
|
||||||
|
p("Continuing will overwrite the existing instance config.")
|
||||||
|
if click.confirm("Do you want to use different instance name?", default=True):
|
||||||
|
self._ask_for_name()
|
||||||
|
if not self.ensure_data_path():
|
||||||
|
p(
|
||||||
|
"Original data path can't be used as it cannot be written to by the current user."
|
||||||
|
" You have to choose a different path."
|
||||||
|
)
|
||||||
|
self._ask_for_data_path()
|
||||||
|
elif self.data_path_not_empty:
|
||||||
|
p(
|
||||||
|
"Original data path can't be used as it's not empty."
|
||||||
|
" You have to choose a different path."
|
||||||
|
)
|
||||||
|
self._ask_for_data_path()
|
||||||
|
if self.backend_unavailable:
|
||||||
|
p(
|
||||||
|
"Original storage backend is no longer available in Red."
|
||||||
|
" You have to choose a different backend."
|
||||||
|
)
|
||||||
|
self._ask_for_storage()
|
||||||
|
|
||||||
|
def _ask_for_name(self) -> None:
|
||||||
|
self.name = get_name("")
|
||||||
|
|
||||||
|
def _ask_for_data_path(self) -> None:
|
||||||
|
while True:
|
||||||
|
self.data_path = Path(
|
||||||
|
get_data_dir(instance_name=self.name, data_path=None, interactive=True)
|
||||||
|
)
|
||||||
|
if not self.ensure_data_path():
|
||||||
|
print("Given path can't be used as it cannot be written to by the current user.")
|
||||||
|
elif self.data_path_not_empty:
|
||||||
|
print("Given path can't be used as it's not empty.")
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
|
||||||
|
def _ask_for_storage(self) -> None:
|
||||||
|
self.storage_type = get_storage_type(None, interactive=True)
|
||||||
|
driver_cls = get_driver_class(self.storage_type)
|
||||||
|
self.storage_details = driver_cls.get_config_details()
|
||||||
|
|
||||||
|
def extractall(self) -> None:
|
||||||
|
to_extract = self.get_tar_members_to_extract()
|
||||||
|
with detailed_progress(unit="files") as progress:
|
||||||
|
progress_tracker = progress.track(to_extract, description="Extracting data")
|
||||||
|
# tar.errorlevel == 0 so errors are printed to stderr
|
||||||
|
self.tar.extractall(path=self.data_path, members=progress_tracker)
|
||||||
|
|
||||||
|
def get_basic_config(self, use_json: bool = False) -> dict:
|
||||||
|
default_dirs = deepcopy(data_manager.basic_config_default)
|
||||||
|
default_dirs["DATA_PATH"] = str(self.data_path)
|
||||||
|
if use_json:
|
||||||
|
default_dirs["STORAGE_TYPE"] = BackendType.JSON.value
|
||||||
|
default_dirs["STORAGE_DETAILS"] = {}
|
||||||
|
else:
|
||||||
|
default_dirs["STORAGE_TYPE"] = self.storage_type.value
|
||||||
|
default_dirs["STORAGE_DETAILS"] = self.storage_details
|
||||||
|
return default_dirs
|
||||||
|
|
||||||
|
async def restore_data(self) -> None:
|
||||||
|
self.extractall()
|
||||||
|
|
||||||
|
# data in backup file is using json
|
||||||
|
save_config(self.name, self.get_basic_config(use_json=True))
|
||||||
|
data_manager.load_basic_configuration(self.name)
|
||||||
|
|
||||||
|
if self.storage_type is not BackendType.JSON:
|
||||||
|
await do_migration(BackendType.JSON, self.storage_type, self.storage_details)
|
||||||
|
save_config(self.name, self.get_basic_config())
|
||||||
|
data_manager.load_basic_configuration(self.name)
|
||||||
|
|
||||||
|
if self.restore_downloader:
|
||||||
|
driver_cls = get_driver_class(self.storage_type)
|
||||||
|
await driver_cls.initialize(**self.storage_details)
|
||||||
|
try:
|
||||||
|
await _downloader._init_without_bot(CogManager())
|
||||||
|
await _downloader._restore_from_backup()
|
||||||
|
finally:
|
||||||
|
await driver_cls.teardown()
|
||||||
|
elif self.backup_version == 1:
|
||||||
|
self._info(
|
||||||
|
"Downloader's data isn't included in the backup file"
|
||||||
|
" - this backup was created with Red 3.5.24 or older."
|
||||||
|
)
|
||||||
|
elif not self.can_restore_downloader:
|
||||||
|
self._warning("Downloader's data isn't included in the backup file.")
|
||||||
|
|
||||||
|
async def run(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
interactive: bool,
|
||||||
|
instance_name: str = "",
|
||||||
|
data_path: Optional[Path] = None,
|
||||||
|
backend: Optional[BackendType] = None,
|
||||||
|
use_sane_default_data_path: bool = False,
|
||||||
|
) -> None:
|
||||||
|
storage_details = {}
|
||||||
|
if backend:
|
||||||
|
driver_cls = get_driver_class(backend)
|
||||||
|
storage_details = driver_cls.get_config_details()
|
||||||
|
print("\n---")
|
||||||
|
self.print_instance_data()
|
||||||
|
|
||||||
|
if use_sane_default_data_path:
|
||||||
|
data_path = get_default_data_path(instance_name or self.name)
|
||||||
|
if instance_name or data_path or backend:
|
||||||
|
print("\nThe following settings have been overridden with command options:")
|
||||||
|
if instance_name:
|
||||||
|
self.name = instance_name
|
||||||
|
print(" Instance name:", instance_name)
|
||||||
|
if data_path:
|
||||||
|
self.data_path = data_path
|
||||||
|
print(" Data path:", data_path)
|
||||||
|
if backend:
|
||||||
|
self.storage_type = backend
|
||||||
|
self.storage_details = storage_details
|
||||||
|
print(" Storage backend:", self.STORAGE_BACKENDS[backend])
|
||||||
|
self.print_storage_details(original=False)
|
||||||
|
|
||||||
|
self.ask_for_changes(interactive=interactive)
|
||||||
|
await self.restore_data()
|
||||||
|
|
||||||
|
print("Restore process has been completed.")
|
||||||
|
|
||||||
|
|
||||||
|
async def restore_instance(
|
||||||
|
backup_path: Path,
|
||||||
|
*,
|
||||||
|
interactive: bool,
|
||||||
|
skip_downloader_restore: bool,
|
||||||
|
instance_name: str,
|
||||||
|
data_path: Optional[Path],
|
||||||
|
use_sane_default_data_path: bool = False,
|
||||||
|
backend: Optional[str],
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
tar = tarfile.open(backup_path)
|
||||||
|
except tarfile.ReadError:
|
||||||
|
print(
|
||||||
|
"We couldn't open the given backup file. Make sure that you're passing correct file."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
print("Hello! This command will guide you through restore process.")
|
||||||
|
if interactive:
|
||||||
|
restore_downloader = False if skip_downloader_restore else None
|
||||||
|
else:
|
||||||
|
restore_downloader = not skip_downloader_restore
|
||||||
|
with tar:
|
||||||
|
# The filter functionality exists on Python 3.11.4+.
|
||||||
|
# We'll use the value consistent with the 3.11's default
|
||||||
|
# since there's no reason we shouldn't trust the archive
|
||||||
|
# that we generated ourselves.
|
||||||
|
tar.extraction_filter = getattr(tarfile, "fully_trusted_filter", None)
|
||||||
|
restore_info = RestoreInfo.from_tar(
|
||||||
|
tar,
|
||||||
|
restore_downloader=restore_downloader,
|
||||||
|
)
|
||||||
|
await restore_info.run(
|
||||||
|
interactive=interactive,
|
||||||
|
instance_name=instance_name,
|
||||||
|
data_path=data_path,
|
||||||
|
use_sane_default_data_path=use_sane_default_data_path,
|
||||||
|
backend=get_target_backend(backend) if backend else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@click.group(invoke_without_command=True)
|
@click.group(invoke_without_command=True)
|
||||||
@click.option(
|
@click.option(
|
||||||
"--debug",
|
"--debug",
|
||||||
@@ -561,6 +949,89 @@ def backup(instance: str, destination_folder: Path) -> None:
|
|||||||
asyncio_run(create_backup(instance, destination_folder))
|
asyncio_run(create_backup(instance, destination_folder))
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command()
|
||||||
|
@click.argument(
|
||||||
|
"backup_file",
|
||||||
|
type=click.Path(file_okay=True, resolve_path=True, readable=True, path_type=Path),
|
||||||
|
metavar="<BACKUP_FILE>",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--no-prompt",
|
||||||
|
"interactive",
|
||||||
|
is_flag=True,
|
||||||
|
default=True,
|
||||||
|
help="Don't ask for user input during the process. Most of the values",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--no-restore-downloader",
|
||||||
|
"skip_downloader_restore",
|
||||||
|
is_flag=True,
|
||||||
|
default=False,
|
||||||
|
help="Skip restoring of 3rd-party repos and cogs installed through Downloader.",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--instance-name",
|
||||||
|
type=str,
|
||||||
|
default="",
|
||||||
|
help=(
|
||||||
|
"Name of the new instance. By default, the name stored in the backup will be used"
|
||||||
|
" and, if the --no-prompt option was not specified, you will be able to change this"
|
||||||
|
" before restoring"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--data-path",
|
||||||
|
type=click.Path(exists=False, dir_okay=True, file_okay=False, writable=True, path_type=Path),
|
||||||
|
default=None,
|
||||||
|
help=(
|
||||||
|
"Data path of the new instance. If this option and --no-prompt are omitted,"
|
||||||
|
" you will be asked for this."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--use-sane-default-data-path",
|
||||||
|
is_flag=True,
|
||||||
|
default=False,
|
||||||
|
help=(
|
||||||
|
"Use the sane default data path derived from the instance name instead of using data path"
|
||||||
|
" from the backup or specifying --data-path option."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--backend",
|
||||||
|
type=click.Choice(["json", "postgres"]),
|
||||||
|
default=None,
|
||||||
|
help=(
|
||||||
|
"Choose a backend type for the new instance."
|
||||||
|
" By default, the backend of the backed up instance will be used"
|
||||||
|
" and, if the --no-prompt option was not specified, you will be able to change this"
|
||||||
|
" before restoring.\n"
|
||||||
|
"Note: Choosing PostgreSQL will prevent the setup from being completely non-interactive."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def restore(
|
||||||
|
backup_file: Path,
|
||||||
|
interactive: bool,
|
||||||
|
skip_downloader_restore: bool,
|
||||||
|
instance_name: str,
|
||||||
|
data_path: Optional[Path],
|
||||||
|
use_sane_default_data_path: bool,
|
||||||
|
backend: Optional[str],
|
||||||
|
) -> None:
|
||||||
|
"""Restore instance."""
|
||||||
|
asyncio.run(
|
||||||
|
restore_instance(
|
||||||
|
backup_file,
|
||||||
|
interactive=interactive,
|
||||||
|
skip_downloader_restore=skip_downloader_restore,
|
||||||
|
instance_name=instance_name,
|
||||||
|
data_path=data_path,
|
||||||
|
use_sane_default_data_path=use_sane_default_data_path,
|
||||||
|
backend=backend,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def run_cli():
|
def run_cli():
|
||||||
# Setuptools entry point script stuff...
|
# Setuptools entry point script stuff...
|
||||||
try:
|
try:
|
||||||
|
|||||||
Reference in New Issue
Block a user