Skip to content

Commit

Permalink
backup_format support
Browse files Browse the repository at this point in the history
tar backup!
  • Loading branch information
Fallen-Breath committed Mar 13, 2023
1 parent 361d576 commit 86fa53c
Show file tree
Hide file tree
Showing 4 changed files with 136 additions and 38 deletions.
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,22 @@ mcd_root/
...
```

执行`!!qb back`时,会从备份槽中指定世界名对应的符号链接开始,将所有符号链接以及最终实际的世界文件夹恢复至服务端的对应位置。这表示如果后续服务端的符号链接更改了指向的世界,回档时将恢复到备份时保存的世界,且不同世界的内容不会互相覆盖
执行 `!!qb back` 时,会从备份槽中指定世界名对应的符号链接开始,将所有符号链接以及最终实际的世界文件夹恢复至服务端的对应位置。这表示如果后续服务端的符号链接更改了指向的世界,回档时将恢复到备份时保存的世界,且不同世界的内容不会互相覆盖

### backup_format

备份的储存格式

|| 含义 |
|----------|-------------------------------------------------------------------|
| `plain` | 直接复制文件夹/文件来储存。默认值,这同时也是 v1.8 以前版本的 QBM 唯一支持的储存格式 |
| `tar` | 使用 tar 格式直接打包储存至 `backup.tar` 文件中。推荐使用,可有效减少文件的数量,但无法方便地访问备份里面的文件 |
| `tar_gz` | 使用 tar.gz 格式压缩打包储存至 `backup.tar.gz` 文件中。能减小备份体积,但是备份/回档的耗时将极大增加 |

槽位的备份模式会储存在槽位的 `info.json` 中,并在回档时读取,因此的不同的槽位可以有着不同的储存格式。
若其值不存在,QBM 会假定这个槽位是由旧版 QBM 创建的,并使用默认值 `plain`

若配置文件中的 `backup_format` 非法,则会使用默认值 `plain`

### minimum_permission_level

Expand Down
17 changes: 17 additions & 0 deletions README_en.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,23 @@ mcd_root/

Doing `!!qb back` will restore everything from world name symlink to the final actual world folder in the slot to the server's corresponding place. This implies that if the symlink has changed its target world, the server will be restored to the world when making backup, and the world before restoring will not be overwritten

### backup_format

The format of the stored backup

| Value | Explanation |
|----------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `plain` | Store the backup directly via file / directory copy. The default value, the only supported format in QBM < v1.8 |
| `tar` | Pack the files into `backup.tar` in tar format. Recommend value. It can significantly reduce the file amount. Although you cannot access files inside the backup easily |
| `tar_gz` | Compress the files into `backup.tar.gz` in tar.gz format. The backup size will be smaller, but the time cost in backup / restore will increase quite a lot |

槽位的备份模式会储存在槽位的 `info.json` 中,并在回档时读取。若其值不存在,则使用默认值 `plain`,对应着旧版 QBM 的表现

The backup format of the slot will be stored inside the `info.json` of the slot, and will be read when restoring, so you can have different backup formats in your slots.
If the backup format value doesn't exist, QBM will assume that it's a backup created from old QBM, and use the default `plain` format

If the `backup_format` value is invalid in the config file, the default value `plain` will be used

### minimum_permission_level

Default:
Expand Down
139 changes: 102 additions & 37 deletions quick_backup_multi/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
import os
import re
import shutil
import tarfile
import time
from enum import Enum, auto
from threading import Lock
from typing import Optional, Any, Callable, Tuple

Expand All @@ -24,6 +26,28 @@
operation_name = RText('?')


class CopyWorldIntent(Enum):
backup = auto()
restore = auto()


class BackupFormat(Enum):
plain = auto()
tar = auto()
tar_gz = auto()

@classmethod
def of(cls, mode: str) -> 'BackupFormat':
try:
return cls[mode]
except Exception:
return cls.plain


def get_backup_format() -> BackupFormat:
return BackupFormat.of(config.backup_format)


def tr(translation_key: str, *args) -> RTextMCDRTranslation:
return ServerInterface.get_instance().rtr('quick_backup_multi.{}'.format(translation_key), *args)

Expand All @@ -41,31 +65,71 @@ def command_run(message: Any, text: Any, command: str) -> RTextBase:
return fancy_text.set_hover_text(text).set_click_event(RAction.run_command, command)


def copy_worlds(src: str, dst: str):
for world in config.world_names:
src_path = os.path.join(src, world)
dst_path = os.path.join(dst, world)

while os.path.islink(src_path):
server_inst.logger.info('copying {} -> {} (symbolic link)'.format(src_path, dst_path))
dst_dir = os.path.dirname(dst_path)
if not os.path.isdir(dst_dir):
os.makedirs(dst_dir)
link_path = os.readlink(src_path)
os.symlink(link_path, dst_path)
src_path = link_path if os.path.isabs(link_path) else os.path.normpath(os.path.join(os.path.dirname(src_path), link_path))
dst_path = os.path.join(dst, os.path.relpath(src_path, src))

server_inst.logger.info('copying {} -> {}'.format(src_path, dst_path))
if os.path.isdir(src_path):
shutil.copytree(src_path, dst_path, ignore=lambda path, files: set(filter(config.is_file_ignored, files)))
elif os.path.isfile(src_path):
dst_dir = os.path.dirname(dst_path)
if not os.path.isdir(dst_dir):
os.makedirs(dst_dir)
shutil.copy(src_path, dst_path)
else:
server_inst.logger.warning('{} does not exist while copying ({} -> {})'.format(src_path, src_path, dst_path))
def get_backup_file_name(backup_format: BackupFormat):
if backup_format == BackupFormat.plain:
raise ValueError('plain mode is not supported')
elif backup_format == BackupFormat.tar:
return 'backup.tar'
elif backup_format == BackupFormat.tar_gz:
return 'backup.tar.gz'
else:
raise ValueError('unknown backup mode {}'.format(backup_format))


def copy_worlds(src: str, dst: str, intent: CopyWorldIntent, *, backup_format: Optional[BackupFormat] = None):
if backup_format is None:
backup_format = get_backup_format()
if backup_format == BackupFormat.plain:
for world in config.world_names:
src_path = os.path.join(src, world)
dst_path = os.path.join(dst, world)

while os.path.islink(src_path):
server_inst.logger.info('copying {} -> {} (symbolic link)'.format(src_path, dst_path))
dst_dir = os.path.dirname(dst_path)
if not os.path.isdir(dst_dir):
os.makedirs(dst_dir)
link_path = os.readlink(src_path)
os.symlink(link_path, dst_path)
src_path = link_path if os.path.isabs(link_path) else os.path.normpath(os.path.join(os.path.dirname(src_path), link_path))
dst_path = os.path.join(dst, os.path.relpath(src_path, src))

server_inst.logger.info('copying {} -> {}'.format(src_path, dst_path))
if os.path.isdir(src_path):
shutil.copytree(src_path, dst_path, ignore=lambda path, files: set(filter(config.is_file_ignored, files)))
elif os.path.isfile(src_path):
dst_dir = os.path.dirname(dst_path)
if not os.path.isdir(dst_dir):
os.makedirs(dst_dir)
shutil.copy(src_path, dst_path)
else:
server_inst.logger.warning('{} does not exist while copying ({} -> {})'.format(src_path, src_path, dst_path))
elif backup_format == BackupFormat.tar or backup_format == BackupFormat.tar_gz:
if intent == CopyWorldIntent.restore:
tar_path = os.path.join(src, get_backup_file_name(backup_format))
server_inst.logger.info('extracting {} -> {}'.format(tar_path, dst))
with tarfile.open(tar_path, 'r:*') as backup_file:
backup_file.extractall(path=dst)
else: # backup
if backup_format == BackupFormat.tar_gz:
tar_mode = 'w:gz'
else:
tar_mode = 'w'
if not os.path.isdir(dst):
os.makedirs(dst)
tar_path = os.path.join(dst, get_backup_file_name(backup_format))
with tarfile.open(tar_path, tar_mode) as backup_file:
for world in config.world_names:
src_path = os.path.join(src, world)
server_inst.logger.info('storing {} -> {}'.format(src_path, tar_path))
if os.path.exists(src_path):
def tar_filter(info: tarfile.TarInfo) -> Optional[tarfile.TarInfo]:
if config.is_file_ignored(info.name):
return None
return info
backup_file.add(src_path, arcname=world, filter=tar_filter)
else:
server_inst.logger.warning('{} does not exist while storing'.format(src_path))


def remove_worlds(folder: str):
Expand Down Expand Up @@ -122,11 +186,9 @@ def format_protection_time(time_length: float) -> RTextBase:
return tr('day', round(time_length / 60 / 60 / 24, 2))


def format_slot_info(info_dict: Optional[dict] = None, slot_number: Optional[int] = None) -> Optional[RTextBase]:
def format_slot_info(info_dict: Optional[dict] = None) -> Optional[RTextBase]:
if isinstance(info_dict, dict):
info = info_dict
elif slot_number is not None:
info = get_slot_info(slot_number)
else:
return None

Expand Down Expand Up @@ -162,7 +224,8 @@ def slot_check(source: CommandSource, slot: int) -> Optional[Tuple[int, dict]]:
def create_slot_info(comment: Optional[str]) -> dict:
slot_info = {
'time': format_time(),
'time_stamp': time.time()
'time_stamp': time.time(),
'backup_format': get_backup_format().name,
}
if comment is not None:
slot_info['comment'] = comment
Expand Down Expand Up @@ -301,7 +364,7 @@ def _create_backup(source: CommandSource, comment: Optional[str]):
slot_path = get_slot_path(1)

# copy worlds to backup slot
copy_worlds(config.server_path, slot_path)
copy_worlds(config.server_path, slot_path, CopyWorldIntent.backup)

# create info.json
slot_info = create_slot_info(comment)
Expand Down Expand Up @@ -377,16 +440,17 @@ def _do_restore_backup(source: CommandSource, slot: int):
overwrite_backup_path = os.path.join(config.backup_path, config.overwrite_backup_folder)
if os.path.exists(overwrite_backup_path):
shutil.rmtree(overwrite_backup_path)
copy_worlds(config.server_path, overwrite_backup_path)
copy_worlds(config.server_path, overwrite_backup_path, CopyWorldIntent.backup)
with open(os.path.join(overwrite_backup_path, 'info.txt'), 'w') as f:
f.write('Overwrite time: {}\n'.format(format_time()))
f.write('Confirmed by: {}'.format(source))

slot_folder = get_slot_path(slot)
server_inst.logger.info('Deleting world')
remove_worlds(config.server_path)
server_inst.logger.info('Restore backup ' + slot_folder)
copy_worlds(slot_folder, config.server_path)
backup_format = BackupFormat.of(slot_info.get('backup_format'))
server_inst.logger.info('Restore backup {} (mode={})'.format(slot_folder, backup_format.name))
copy_worlds(slot_folder, config.server_path, CopyWorldIntent.restore, backup_format=backup_format)

source.get_server().start()
except:
Expand Down Expand Up @@ -423,7 +487,8 @@ def format_dir_size(size: int):
backup_size = 0
for i in range(get_slot_count()):
slot_idx = i + 1
slot_info = format_slot_info(slot_number=slot_idx)
slot_info = get_slot_info(slot_idx)
formatted_slot_info = format_slot_info(slot_info)
if size_display:
dir_size = get_dir_size(get_slot_path(slot_idx))
else:
Expand All @@ -434,14 +499,14 @@ def format_dir_size(size: int):
RText(tr('list_backup.slot.header', slot_idx)).h(tr('list_backup.slot.protection', format_protection_time(config.slots[slot_idx - 1].delete_protection))),
' '
)
if slot_info is not None:
if formatted_slot_info is not None:
text += RTextList(
RText('[▷] ', color=RColor.green).h(tr('list_backup.slot.restore', slot_idx)).c(RAction.run_command, f'{Prefix} back {slot_idx}'),
RText('[×] ', color=RColor.red).h(tr('list_backup.slot.delete', slot_idx)).c(RAction.suggest_command, f'{Prefix} del {slot_idx}')
)
if size_display:
text += '§2{}§r '.format(format_dir_size(dir_size))
text += slot_info
text += RText(format_dir_size(dir_size) + ' ', RColor.dark_green).h(BackupFormat.of(slot_info.get('backup_format')).name)
text += formatted_slot_info
print_message(source, text, prefix='')
if size_display:
print_message(source, tr('list_backup.total_space', format_dir_size(backup_size)), prefix='')
Expand Down
1 change: 1 addition & 0 deletions quick_backup_multi/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ class Configuration(Serializable):
world_names: List[str] = [
'world'
]
backup_format: str = 'plain' # "plain", "tar", "tar_gz"
# 0:guest 1:user 2:helper 3:admin 4:owner
minimum_permission_level: Dict[str, int] = {
'make': 1,
Expand Down

0 comments on commit 86fa53c

Please sign in to comment.