diff --git a/common/bitbase.py b/common/bitbase.py index 46f770f6e..4888d6b54 100644 --- a/common/bitbase.py +++ b/common/bitbase.py @@ -9,19 +9,52 @@ from enum import Enum from pathlib import Path +# Workaround: Mostly relevant on TravisCI but not exclusively. +# While unittesting and without regular invocation of BIT the GNU gettext +# class-based API isn't setup yet. +# pylint: disable=duplicate-code +try: + _('Warning') +except NameError: + def _(val): + return val + # See issue #1734 and #1735 URL_ENCRYPT_TRANSITION = 'https://github.com/bit-team/backintime' \ '/blob/-/doc/ENCRYPT_TRANSITION.md' +SSH_CIPHERS = { + 'default': _('Default'), + 'aes128-ctr': 'AES128-CTR', + 'aes192-ctr': 'AES192-CTR', + 'aes256-ctr': 'AES256-CTR', + 'arcfour256': 'ARCFOUR256', + 'arcfour128': 'ARCFOUR128', + 'aes128-cbc': 'AES128-CBC', + '3des-cbc': '3DES-CBC', + 'blowfish-cbc': 'Blowfish-CBC', + 'cast128-cbc': 'Cast128-CBC', + 'aes192-cbc': 'AES192-CBC', + 'aes256-cbc': 'AES256-CBC', + 'arcfour': 'ARCFOUR' +} + USER_MANUAL_ONLINE_URL = 'https://backintime.readthedocs.io' USER_MANUAL_LOCAL_PATH = Path('/') / 'usr' / 'share' / 'doc' / \ 'backintime-common' / 'manual' / 'index.html' class TimeUnit(Enum): - """Describe time units used in context of scheduling. - """ + """Describe time units used in context of scheduling.""" HOUR = 10 # Config.HOUR DAY = 20 # Config.DAY WEEK = 30 # Config.WEEK MONTH = 40 # Config.MONTH + YEAR = 80 # Config.Year + + +class StorageSizeUnit(Enum): + """Describe the units used to express the size of storage devices or file + system objects.""" + MB = 10 # Config.DISK_UNIT_MB + GB = 20 # Config.DISK_UNIT_GB diff --git a/common/config.py b/common/config.py index bc36e89da..99cfc4ac5 100644 --- a/common/config.py +++ b/common/config.py @@ -37,7 +37,7 @@ _('Warning') except NameError: _ = lambda val: val - +import bitbase import tools import configfile import logger @@ -61,6 +61,7 @@ class Config(configfile.ConfigFileWithProfiles): CONFIG_VERSION = 6 """Latest or highest possible version of Back in Time's config file.""" + # Schedule mode codes NONE = 0 AT_EVERY_BOOT = 1 _5_MIN = 2 @@ -284,21 +285,7 @@ def __init__(self, config_path=None, data_path=None): ) } - self.SSH_CIPHERS = { - 'default': _('Default'), - 'aes128-ctr': 'AES128-CTR', - 'aes192-ctr': 'AES192-CTR', - 'aes256-ctr': 'AES256-CTR', - 'arcfour256': 'ARCFOUR256', - 'arcfour128': 'ARCFOUR128', - 'aes128-cbc': 'AES128-CBC', - '3des-cbc': '3DES-CBC', - 'blowfish-cbc': 'Blowfish-CBC', - 'cast128-cbc': 'Cast128-CBC', - 'aes192-cbc': 'AES192-CBC', - 'aes256-cbc': 'AES256-CBC', - 'arcfour': 'ARCFOUR' - } + self.SSH_CIPHERS = bitbase.SSH_CIPHERS def save(self): self.setIntValue('config.version', self.CONFIG_VERSION) @@ -1365,7 +1352,7 @@ def preparePath(self, path): def isConfigured(self, profile_id=None): """Checks if the program is configured. - It is assumed as configured if a snapshot path (backup destination) is + It is assumed as configured if a snapshot path (backup destination) and include files/directories (backup source) are given. """ path = self.snapshotsPath(profile_id) diff --git a/common/konfig.py b/common/konfig.py new file mode 100644 index 000000000..f4889a9f9 --- /dev/null +++ b/common/konfig.py @@ -0,0 +1,1372 @@ +# SPDX-FileCopyrightText: © 2024 Christian BUHTZ +# +# SPDX-License-Identifier: GPL-2.0-or-later +# +# This file is part of the program "Back In Time" which is released under GNU +# General Public License v2 (GPLv2). +# See file LICENSE or go to . +# pylint: disable=too-many-lines,anomalous-backslash-in-string +"""Configuration mangament. +""" +from __future__ import annotations +import configparser +import getpass +import os +import socket +import re +from typing import Union, Any, Optional +from pathlib import Path +from io import StringIO, TextIOWrapper +import singleton +import logger +from bitbase import TimeUnit, StorageSizeUnit + +# Workaround: Mostly relevant on TravisCI but not exclusively. +# While unittesting and without regular invocation of BIT the GNU gettext +# class-based API isn't setup yet. +# The bigger problem with config.py is that it do use translatable strings. +# Strings like this do not belong into a config file or its context. +try: + _('Warning') +except NameError: + def _(val): + return val + + +class Profile: # pylint: disable=too-many-public-methods + """Manages access to profile-specific configuration data.""" + _DEFAULT_VALUES = { + 'snapshots.mode': 'local', + 'snapshots.path.host': socket.gethostname(), + 'snapshots.path.user': getpass.getuser(), + 'snapshots.ssh.port': 22, + 'snapshots.ssh.cipher': 'default', + 'snapshots.ssh.user': getpass.getuser(), + 'snapshots.ssh.private_key_file': + str(Path('~') / '.ssh' / 'id_rsa'), + 'snapshots.ssh.max_arg_length': 0, + 'snapshots.ssh.check_commands': True, + 'snapshots.ssh.check_ping': True, + 'snapshots.local_encfs.path': '', + 'snapshots.password.save': False, + 'snapshots.include': [], + 'snapshots.exclude': [], + 'snapshots.exclude.bysize.enabled': False, + 'snapshots.exclude.bysize.value': 500, + 'schedule.mode': 0, + 'schedule.debug': False, + 'schedule.time': 0, + 'schedule.day': 1, + 'schedule.weekday': 7, + 'schedule.custom_time': '8,12,18,23', + 'schedule.repeatedly.period': 1, + 'schedule.repeatedly.unit': TimeUnit.DAY, + 'snapshots.remove_old_snapshots.enabled': True, + 'snapshots.remove_old_snapshots.value': 10, + 'snapshots.remove_old_snapshots.unit': TimeUnit.YEAR, + 'snapshots.min_free_space.enabled': True, + 'snapshots.min_free_space.value': 1, + 'snapshots.min_free_space.unit': StorageSizeUnit.GB, + 'snapshots.min_free_inodes.enabled': True, + 'snapshots.min_free_inodes.value': 2, + 'snapshots.dont_remove_named_snapshots': True, + 'snapshots.smart_remove': False, + 'snapshots.smart_remove.keep_all': 2, + 'snapshots.smart_remove.keep_one_per_day': 7, + 'snapshots.smart_remove.keep_one_per_week': 4, + 'snapshots.smart_remove.keep_one_per_month': 24, + 'snapshots.smart_remove.run_remote_in_background': False, + 'snapshots.notify.enabled': True, + 'snapshots.backup_on_restore.enabled': True, + 'snapshots.cron.nice': True, + 'snapshots.cron.ionice': True, + 'snapshots.user_backup.ionice': False, + 'snapshots.ssh.nice': False, + 'snapshots.ssh.ionice': False, + 'snapshots.local.nocache': False, + 'snapshots.ssh.nocache': False, + 'snapshots.cron.redirect_stdout': True, + 'snapshots.cron.redirect_stderr': False, + 'snapshots.bwlimit.enabled': False, + 'snapshots.bwlimit.value': 3000, + 'snapshots.no_on_battery': False, + 'snapshots.preserve_acl': False, + 'snapshots.preserve_xattr': False, + 'snapshots.copy_unsafe_links': False, + 'snapshots.copy_links': False, + 'snapshots.one_file_system': False, + 'snapshots.rsync_options.enabled': False, + 'snapshots.ssh.prefix.enabled': False, + # Config.DEFAULT_SSH_PREFIX + 'snapshots.ssh.prefix.value': 'PATH=/opt/bin:/opt/sbin:\\$PATH', + 'snapshots.continue_on_errors': True, + 'snapshots.use_checksum': False, + 'snapshots.log_level': 3, + 'snapshots.take_snapshot_regardless_of_changes': False, + 'global.use_flock': False, + } + + def __init__(self, profile_id: int, config: Konfig): + self._config = config + self._prefix = f'profile{profile_id}' + + def __getitem__(self, key: str) -> Any: + """Select a field of the profiles config by its name as a string and + return its value. + + For example `self['field.name']` will return the value of field + `profile.field.name`. + + Return: The value of the config field if present. Otherwise a default + value taken from `self._DEFAULT_VALUES`. + + Raises: + KeyError if the field or its default value is unknown. + """ + try: + return self._config[f'{self._prefix}.{key}'] + except KeyError: + return self._DEFAULT_VALUES[key] + + def __setitem__(self, key: str, val: Any) -> None: + """Set the value of a field of the profiles config. + + For example `self['field.name'] = 7` will set the value `7` to the + field `profile.field.name`. + + Raises: + KeyError if the field is unknown. + """ + self._config[f'{self._prefix}.{key}'] = val + + def __delitem__(self, key: str) -> None: + del self._config[f'{self._prefix}.{key}'] + + @property + def snapshots_mode(self) -> str: + """Use mode (or backend) for this snapshot. Look at 'man + backintime' section 'Modes'. + + { + 'values': 'local|local_encfs|ssh|ssh_encfs', + 'default': 'local', + } + """ + return self['snapshots.mode'] + + @snapshots_mode.setter + def snapshots_mode(self, val: str) -> None: + self['snapshots.mode'] = val + + @property + def snapshots_path(self) -> str: + """Where to save snapshots in mode 'local'. This path must contain + a folderstructure like 'backintime///'. + + { + 'values': 'absolute path', + } + """ + raise NotImplementedError( + 'see original in Config class. See also ' + 'Config.snapshotsFullPath(self, profile_id = None)') + + # return self['snapshots.path'] + + @snapshots_path.setter + def snapshots_path(self, path): + raise NotImplementedError('see original in Config class.') + + @property + def snapshots_path_host(self) -> str: + """Set Host for snapshot path. + + { 'values': 'local hostname' } + """ + return self['snapshots.path.host'] + + @snapshots_path_host.setter + def snapshots_path_host(self, value: str) -> None: + self['snapshots.path.host'] = value + + @property + def snapshots_path_user(self) -> str: + """Set User for snapshot path. + + { 'values': 'local username' } + """ + return self['snapshots.path.user'] + + @snapshots_path_user.setter + def snapshots_path_user(self, value: str) -> None: + self['snapshots.path.user'] = value + + @property + def snapshots_path_profileid(self) -> str: + """Set Profile-ID for snapshot path + + { + 'values': '1-99999', + 'default': 'current Profile-ID' + } + """ + try: + return self['snapshots.path.profile'] + except KeyError: + # Extract number from field prefix + # e.g. "profile1" -> "1" + return self._prefix.replace('profile', '') + + @snapshots_path_profileid.setter + def snapshots_path_profileid(self, value: str) -> None: + self['snapshots.path.profile'] = value + + @property + def ssh_snapshots_path(self) -> str: + """Snapshot path on remote host. If the path is relative (no + leading '/') it will start from remote Users homedir. An empty path + will be replaced with './'. + + { + 'values': 'absolute or relative path', + } + + """ + return self['snapshots.ssh.path'] + + @ssh_snapshots_path.setter + def ssh_snapshots_path(self, path): + raise NotImplementedError('see original in Config class.') + + @property + def ssh_host(self) -> str: + """Remote host used for mode 'ssh' and 'ssh_encfs'. + + { + 'values': 'IP or domain address', + } + """ + return self['snapshots.ssh.host'] + + @ssh_host.setter + def ssh_host(self, value: str) -> None: + self['snapshots.ssh.host'] = value + + @property + def ssh_port(self) -> int: + """SSH Port on remote host. + + { + 'values': '0-65535', + 'default': 22, + } + """ + return self['snapshots.ssh.port'] + + @ssh_port.setter + def ssh_port(self, value: int) -> None: + self['snapshots.ssh.port'] = value + + @property + def ssh_user(self) -> str: + """Remote SSH user. + + { + 'default': 'local users name', + 'values': 'text', + } + """ + return self['snapshots.ssh.user'] + + @ssh_user.setter + def ssh_user(self, value: str) -> None: + self['snapshots.ssh.user'] = value + + @property + def ssh_cipher(self) -> str: + """Cipher that is used for encrypting the SSH tunnel. Depending on + the environment (network bandwidth, cpu and hdd performance) a + different cipher might be faster. + + { + 'values': 'default | aes192-cbc | aes256-cbc | aes128-ctr ' \ + '| aes192-ctr | aes256-ctr | arcfour | arcfour256 ' \ + '| arcfour128 | aes128-cbc | 3des-cbc | ' \ + 'blowfish-cbc | cast128-cbc', + } + """ + return self['snapshots.ssh.cipher'] + + @ssh_cipher.setter + def ssh_cipher(self, value: str) -> None: + self['snapshots.ssh.cipher'] = value + + @property + def ssh_private_key_file(self) -> Path: + """Private key file used for password-less authentication on remote + host. + + { + 'values': 'absolute path to private key file', + 'type': 'str' + } + + """ + raise NotImplementedError('see original in Config class') + # path_string = self['snapshots.ssh.private_key_file'] + # return Path(path_string) + + @ssh_private_key_file.setter + def ssh_private_key_file(self, path: Path) -> None: + self['snapshots.ssh.private_key_file'] = path + + @property + def ssh_proxy_host(self) -> str: + """Proxy host (or jump host) used to connect to remote host. + + { + 'values': 'IP or domain address', + } + """ + return self['snapshots.ssh.proxy_host'] + + @ssh_proxy_host.setter + def ssh_proxy_host(self, value: str) -> None: + self['snapshots.ssh.proxy_host'] = value + + @property + def ssh_proxy_port(self) -> int: + """Port of SSH proxy (jump) host used to connect to remote host. + + { + 'values': '0-65535', + 'default': 22, + } + """ + return self['snapshots.ssh.proxy_port'] + + @ssh_proxy_port.setter + def ssh_proxy_port(self, value: int) -> None: + self['snapshots.ssh.proxy_port'] = value + + @property + def ssh_proxy_user(self) -> str: + """SSH user at proxy (jump) host. + + { + 'default': 'local users name', + 'values': 'text', + } + """ + return self['snapshots.ssh.proxy_user'] + + @ssh_proxy_user.setter + def ssh_proxy_user(self, value: str) -> None: + self['snapshots.ssh.proxy_user'] = value + + @property + def ssh_max_arg_length(self) -> int: + """Maximum command length of commands run on remote host. This can + be tested for all ssh profiles in the configuration with 'python3 + /usr/share/backintime/common/sshMaxArg.py LENGTH'. The value '0' + means unlimited length. + + { + 'values': '0, >700', + } + """ + raise NotImplementedError('see org in Config') + # return self['snapshots.ssh.max_arg_length'] + + @ssh_max_arg_length.setter + def ssh_max_arg_length(self, length: int) -> None: + self['snapshots.ssh.max_arg_length'] = length + + @property + def ssh_check_commands(self) -> bool: + """Check if all commands (used during takeSnapshot) work like + expected on the remote host. + { 'values': 'true|false' } + """ + return self['snapshots.ssh.check_commands'] + + @ssh_check_commands.setter + def ssh_check_commands(self, value: bool) -> None: + self['snapshots.ssh.check_commands'] = value + + @property + def ssh_check_ping_host(self) -> bool: + """Check if the remote host is available before trying to mount. + { 'values': 'true|false' } + """ + return self['snapshots.ssh.check_ping'] + + @ssh_check_ping_host.setter + def ssh_check_ping_host(self, value: bool) -> None: + self['snapshots.ssh.check_ping'] = value + + @property + def local_encfs_path(self) -> Path: + """Where to save snapshots in mode 'local_encfs'. + + { 'values': 'absolute path' } + """ + return self['snapshots.local_encfs.path'] + + @local_encfs_path.setter + def local_encfs_path(self, path: Path): + self['snapshots.local_encfs.path'] = str(path) + + @property + def password_save(self) -> bool: + """Save password to system keyring (gnome-keyring or kwallet). + { 'values': 'true|false' } + """ + raise NotImplementedError( + 'Refactor it first to make the field name mode independed. ' + 'profileN.snapshots.password.save') + # return self['snapshots.password.save'] + + @password_save.setter + def password_save(self, value: bool) -> None: + self['snapshots.password.save'] = value + + @property + def password_use_cache(self) -> None: + """Cache password in RAM so it can be read by cronjobs. + Security issue: root might be able to read that password, too. + { + 'values': 'true|false', + 'default': 'see #1855' + } + """ + raise NotImplementedError( + 'Refactor it first to make the field name mode independed. ' + 'profileN.snapshots.password.use_cache.' + 'See also Issue #1855 about encrypted home dir') + # ??? default = not tools.checkHomeEncrypt() + # return self['snapshots.password.use_cache'] + + @password_use_cache.setter + def password_use_cache(self, value: bool) -> None: + self['snapshots.password.use_cache'] = value + + def _generic_include_exclude_ids(self, inc_exc_str: str) -> tuple[int]: + """Return two list of numeric IDs used for include and exclude values. + + The config file does have lines like this: + + profile1.snapshots.include.1.values + profile1.snapshots.include.2.values + profile1.snapshots.include.3.values + ... + profile1.snapshots.include.8.values + + or + + profile1.snapshots.exclude.1.values + profile1.snapshots.exclude.2.values + profile1.snapshots.exclude.3.values + ... + profile1.snapshots.exclude.8.values + + The numerical value between (in this example 1, 2, 3, 8) is extracted + via regex. + + Return: + A two item tuple, first with include IDs and second with exclude. + """ + rex = re.compile(r'^' + + self._prefix + + r'.snapshots.' + + inc_exc_str + + r'.(\d+).value') + + ids = [] + + # Ugly, I know. Handling of in/exclude will be rewritten soon. So no + # need to fix this. + for item in self._config._conf: # pylint: disable=protected-access + try: + ids.append(int(rex.findall(item)[0])) + except IndexError: + pass + + return tuple(ids) + + def _get_include_ids(self) -> tuple[int]: + """List of numeric IDs used for include values.""" + + return self._generic_include_exclude_ids('include') + + def _get_exclude_ids(self) -> tuple[int]: + """List of numeric IDs used for exclude values.""" + return self._generic_include_exclude_ids('exclude') + + @property + def include(self) -> list[str, int]: # pylint: disable=C0116 + # Man page docu is added manually. See + # create-manpage-backintime-config.sh script. + + # ('name', 0|1) + result = [] + + for id_val in self._get_include_ids(): + result.append( + ( + self[f'snapshots.include.{id_val}.value'], + int(self[f'snapshots.include.{id_val}.type']) + ) + ) + + return result + + @include.setter + def include(self, values: list[str, int]) -> None: + # delete existing values + for id_val in self._get_include_ids(): + del self[f'snapshots.include.{id_val}.value'] + del self[f'snapshots.include.{id_val}.type'] + + for idx, val in enumerate(values, 1): + self[f'snapshots.include.{idx}.value'] = val[0] + self[f'snapshots.include.{idx}.type'] = str(val[1]) + + @property + def exclude(self) -> list[str]: # pylint: disable=C0116 + # Man page docu is added manually. See + # create-manpage-backintime-config.sh script. + result = [] + + for id_val in self._get_exclude_ids(): + result.append(self[f'snapshots.exclude.{id_val}.value']) + + return result + + @exclude.setter + def exclude(self, values: list[str]) -> None: + # delete existing values + for id_val in self._get_exclude_ids(): + del self[f'snapshots.exclude.{id_val}.value'] + + for idx, val in enumerate(values, 1): + self[f'snapshots.exclude.{idx}.value'] = val + + @property + def exclude_by_size_enabled(self) -> bool: + """Enable exclude files by size.""" + return self['snapshots.exclude.bysize.enabled'] + + @exclude_by_size_enabled.setter + def exclude_by_size_enabled(self, value: bool) -> None: + self['snapshots.exclude.bysize.enabled'] = value + + @property + def exclude_by_size(self) -> int: + """Exclude files bigger than value in MiB. With 'Full rsync mode' + disabled this will only affect new files because for rsync this is a + transfer option, not an exclude option. So big files that has been + backed up before will remain in snapshots even if they had changed. + """ + return self['snapshots.exclude.bysize.value'] + + @exclude_by_size.setter + def exclude_by_size(self, value): + self['snapshots.exclude.bysize.value'] = value + + @property + def schedule_mode(self) -> int: + """Which schedule used for crontab. The crontab entry will be + generated with 'backintime check-config'.\n + 0 = Disabled\n 1 = at every boot\n 2 = every 5 minute\n + 4 = every 10 minute\n 7 = every 30 minute\n10 = every hour\n + 12 = every 2 hours\n14 = every 4 hours\n16 = every 6 hours\n + 18 = every 12 hours\n19 = custom defined hours\n20 = every day\n + 25 = daily anacron\n27 = when drive get connected\n30 = every week\n + 40 = every month\n80 = every year + + { + 'values': '0|1|2|4|7|10|12|14|16|18|19|20|25|27|30|40|80' + } + """ + return self['schedule.mode'] + + @schedule_mode.setter + def schedule_mode(self, value: int) -> None: + self['schedule.mode'] = value + + @property + def schedule_debug(self) -> bool: + """Enable debug output to system log for schedule mode.""" + return self['schedule.debug'] + + @schedule_debug.setter + def schedule_debug(self, value: bool) -> None: + self['schedule.debug'] = value + + @property + def schedule_time(self) -> int: + """Position-coded number with the format "hhmm" to specify the hour + and minute the cronjob should start (eg. 2015 means a quarter + past 8pm). Leading zeros can be omitted (eg. 30 = 0030). + Only valid for \\fIprofile.schedule.mode\\fR = 20 (daily), + 30 (weekly), 40 (monthly) and 80 (yearly). + { 'values': '0-2400' } + """ + return self['schedule.time'] + + @schedule_time.setter + def schedule_time(self, value: int) -> None: + self['schedule.time'] = value + + @property + def schedule_day(self) -> int: + """Which day of month the cronjob should run? Only valid for + \\fIprofile.schedule.mode\\fR >= 40. + { 'values': '1-28' } + """ + return self['schedule.day'] + + @schedule_day.setter + def schedule_day(self, value: int) -> None: + self['schedule.day'] = value + + @property + def schedule_weekday(self) -> int: + """Which day of week the cronjob should run? Only valid for + \\fIprofile.schedule.mode\\fR = 30. + { 'values': '1 (monday) to 7 (sunday)' } + """ + return self['schedule.weekday'] + + @schedule_weekday.setter + def schedule_weekday(self, value: int) -> None: + self['schedule.weekday'] = value + + @property + def custom_backup_time(self) -> str: + """Custom hours for cronjob. Only valid for + \\fIprofile.schedule.mode\\fR = 19 + { 'values': 'comma separated int (8,12,18,23) or */3;8,12,18,23' } + """ + return self['schedule.custom_time'] + + @custom_backup_time.setter + def custom_backup_time(self, value: str) -> None: + self['schedule.custom_time'] = value + + @property + def schedule_repeated_period(self) -> int: + """How many units to wait between new snapshots with anacron? Only + valid for \\fIprofile.schedule.mode\\fR = 25|27. + """ + return self['schedule.repeatedly.period'] + + @schedule_repeated_period.setter + def schedule_repeated_period(self, value: int) -> None: + self['schedule.repeatedly.period'] = value + + @property + def schedule_repeated_unit(self) -> int: + """Units to wait between new snapshots with anacron.\n + 10 = hours\n20 = days\n30 = weeks\n40 = months\n + Only valid for \\fIprofile.schedule.mode\\fR = 25|27; + { 'values': '10|20|30|40' } + """ + return self['schedule.repeatedly.unit'] + + @schedule_repeated_unit.setter + def schedule_repeated_unit(self, value: int) -> None: + self['schedule.repeatedly.unit'] = value + + @property + def remove_old_snapshots_enabled(self) -> bool: + """Remove all snapshots older than value + unit. + """ + return self['snapshots.remove_old_snapshots.enabled'] + + @remove_old_snapshots_enabled.setter + def remove_old_snapshots_enabled(self, enabled: bool) -> None: + self['snapshots.remove_old_snapshots.enabled'] = enabled + + @property + def remove_old_snapshots_value(self) -> int: + """Snapshots older than this times units will be removed.""" + return self['snapshots.remove_old_snapshots.value'] + + @remove_old_snapshots_value.setter + def remove_old_snapshots_value(self, value: int) -> None: + self['snapshots.remove_old_snapshots.value'] = value + + @property + def remove_old_snapshots_unit(self) -> TimeUnit: + """Time unit to use to calculate removing of old snapshots. + 20 = days; 30 = weeks; 80 = years + { + 'values': '20|30|80' + } + """ + return self['snapshots.remove_old_snapshots.unit'] + + @remove_old_snapshots_unit.setter + def remove_old_snapshots_unit(self, unit: TimeUnit) -> None: + self['snapshots.remove_old_snapshots.unit'] = unit + + @property + def min_free_space_enabled(self) -> bool: + """Remove snapshots until \\fIprofile.snapshots.min_free_space. + value\\fR free space is reached. + """ + return self['snapshots.min_free_space.enabled'] + + @min_free_space_enabled.setter + def min_free_space_enabled(self, enable: bool) -> None: + self['snapshots.min_free_space.enabled'] = enable + + @property + def min_free_space_value(self) -> int: + """Keep at least value + unit free space.""" + return self['snapshots.min_free_space.value'] + + @min_free_space_value.setter + def min_free_space_value(self, value: int) -> None: + self['snapshots.min_free_space.value'] = value + + @property + def min_free_space_unit(self) -> StorageSizeUnit: + """10 = MB\n20 = GB + { 'values': '10|20' } + """ + return self['snapshots.min_free_space.unit'] + + @min_free_space_unit.setter + def min_free_space_unit(self, unit: StorageSizeUnit) -> None: + self['snapshots.min_free_space.unit'] = unit + + @property + def min_free_inodes_enabled(self) -> bool: + """Remove snapshots until + \\fIprofile.snapshots.min_free_inodes.value\\fR + free inodes in % is reached. + """ + return self['snapshots.min_free_inodes.enabled'] + + @min_free_inodes_enabled.setter + def min_free_inodes_enabled(self, enable: bool) -> None: + self['snapshots.min_free_inodes.enabled'] = enable + + @property + def min_free_inodes_value(self) -> int: + """Keep at least value % free inodes. + { 'values': '1-15' } + """ + return self['snapshots.min_free_inodes.value'] + + @min_free_inodes_value.setter + def min_free_inodes_value(self, value: int) -> None: + self['snapshots.min_free_inodes.value'] = value + + @property + def dont_remove_named_snapshots(self) -> bool: + """Keep snapshots with names during smart_remove.""" + return self['snapshots.dont_remove_named_snapshots'] + + @dont_remove_named_snapshots.setter + def dont_remove_named_snapshots(self, value: bool) -> None: + """Keep snapshots with names during smart_remove.""" + self['snapshots.dont_remove_named_snapshots'] = value + + @property + def keep_named_snapshots(self) -> bool: + """Keep snapshots with names during smart_remove.""" + return self.dont_remove_named_snapshots + + @keep_named_snapshots.setter + def keep_named_snapshots(self, value: bool) -> None: + self.dont_remove_named_snapshots = value + + @property + def smart_remove(self) -> bool: + """Run smart_remove to clean up old snapshots after a new snapshot was + created.""" + return self['snapshots.smart_remove'] + + @smart_remove.setter + def smart_remove(self, enable: bool) -> None: + self['snapshots.smart_remove'] = enable + + @property + def smart_remove_keep_all(self) -> int: + """Keep all snapshots for X days.""" + return self['snapshots.smart_remove.keep_all'] + + @smart_remove_keep_all.setter + def smart_remove_keep_all(self, days: int) -> None: + self['snapshots.smart_remove.keep_all'] = days + + @property + def smart_remove_keep_one_per_day(self) -> int: + """Keep one snapshot per day for X days.""" + return self['snapshots.smart_remove.keep_one_per_day'] + + @smart_remove_keep_one_per_day.setter + def smart_remove_keep_one_per_day(self, days: int) -> None: + self['snapshots.smart_remove.keep_one_per_day'] = days + + @property + def smart_remove_keep_one_per_week(self) -> int: + """Keep one snapshot per week for X weeks.""" + return self['snapshots.smart_remove.keep_one_per_week'] + + @smart_remove_keep_one_per_week.setter + def smart_remove_keep_one_per_week(self, weeks: int) -> None: + self['snapshots.smart_remove.keep_one_per_week'] = weeks + + @property + def smart_remove_keep_one_per_month(self) -> int: + """Keep one snapshot per month for X months.""" + return self['snapshots.smart_remove.keep_one_per_month'] + + @smart_remove_keep_one_per_month.setter + def smart_remove_keep_one_per_month(self, months: int) -> None: + self['snapshots.smart_remove.keep_one_per_month'] = months + + @property + def smart_remove_run_remote_in_background(self) -> bool: + """If using modes SSH or SSH-encrypted, run smart_remove in background + on remote machine""" + return self['snapshots.smart_remove.run_remote_in_background'] + + @smart_remove_run_remote_in_background.setter + def smart_remove_run_remote_in_background(self, enable: bool) -> None: + self['snapshots.smart_remove.run_remote_in_background'] = enable + + @property + def notify(self) -> bool: + """Display notifications (errors, warnings) through libnotify or DBUS. + """ + return self['snapshots.notify.enabled'] + + @notify.setter + def notify(self, enable: bool) -> None: + self['snapshots.notify.enabled'] = enable + + @property + def backup_on_restore(self) -> bool: + """Rename existing files before restore into FILE.backup.YYYYMMDD""" + return self['snapshots.backup_on_restore.enabled'] + + @backup_on_restore.setter + def backup_on_restore(self, enable: bool) -> None: + self['snapshots.backup_on_restore.enabled'] = enable + + @property + def nice_on_cron(self) -> bool: + """Run cronjobs with nice-Value 19. This will give Back In Time the + lowest CPU priority to not interrupt any other working process.""" + return self['snapshots.cron.nice'] + + @nice_on_cron.setter + def nice_on_cron(self, enable: bool) -> None: + self['snapshots.cron.nice'] = enable + + @property + def ionice_on_cron(self) -> bool: + """Run cronjobs with 'ionice' and class 2 and level 7. This will give + Back In Time the lowest IO bandwidth priority to not interrupt any + other working process. + """ + return self['snapshots.cron.ionice'] + + @ionice_on_cron.setter + def ionice_on_cron(self, enable: bool) -> None: + self['snapshots.cron.ionice'] = enable + + @property + def ionice_on_user(self) -> bool: + """Run Back In Time with 'ionice' and class 2 and level 7 when taking + a manual snapshot. This will give Back In Time the lowest IO bandwidth + priority to not interrupt any other working process. + """ + return self['snapshots.user_backup.ionice'] + + @ionice_on_user.setter + def ionice_on_user(self, enable: bool) -> None: + self['snapshots.user_backup.ionice'] = enable + + @property + def nice_on_remote(self) -> bool: + """Run rsync and other commands on remote host with 'nice' value 19.""" + return self['snapshots.ssh.nice'] + + @nice_on_remote.setter + def nice_on_remote(self, enable: bool) -> None: + self['snapshots.ssh.nice'] = enable + + @property + def ionice_on_remote(self) -> bool: + """Run rsync and other commands on remote host with + 'ionice' and class 2 and level 7.""" + return self['snapshots.ssh.ionice'] + + @ionice_on_remote.setter + def ionice_on_remote(self, enable: bool) -> None: + self['snapshots.ssh.ionice'] = enable + + @property + def nocache_on_local(self) -> bool: + """Run rsync on local machine with 'nocache'. + This will prevent files from being cached in memory.""" + return self['snapshots.local.nocache'] + + @nocache_on_local.setter + def nocache_on_local(self, enable: bool) -> None: + self['snapshots.local.nocache'] = enable + + @property + def nocache_on_remote(self) -> bool: + """Run rsync on remote host with 'nocache'. + This will prevent files from being cached in memory.""" + return self['snapshots.ssh.nocache'] + + @nocache_on_remote.setter + def nocache_on_remote(self, enable: bool) -> None: + self['snapshots.ssh.nocache'] = enable + + @property + def redirect_stdout_in_cron(self) -> bool: + """Redirect stdout to /dev/null in cronjobs.""" + return self['snapshots.cron.redirect_stdout'] + + @redirect_stdout_in_cron.setter + def redirect_stdout_in_cron(self, enable: bool) -> None: + self['snapshots.cron.redirect_stdout'] = enable + + @property + def redirect_stderr_in_cron(self) -> bool: + """Redirect stderr to /dev/null in cronjobs.""" + # Dev note (buhtz, 2024-09): Makes not much sense to me, to have a + # depended default value here. Can't find something helpful in the git + # logs about it. + # if self.isConfigured(profile_id): + # default = True + # else: + # default = self.DEFAULT_REDIRECT_STDERR_IN_CRON + return self['snapshots.cron.redirect_stderr'] + + @redirect_stderr_in_cron.setter + def redirect_stderr_in_cron(self, enable: bool) -> None: + self['snapshots.cron.redirect_stderr'] = enable + + @property + def bw_limit_enabled(self) -> bool: + """Limit rsync bandwidth usage over network. Use this with mode SSH. + For mode Local you should rather use ionice.""" + return self['snapshots.bwlimit.enabled'] + + @bw_limit_enabled.setter + def bw_limit_enabled(self, enable: bool) -> None: + self['snapshots.bwlimit.enabled'] = enable + + @property + def bw_limit(self) -> int: + """Bandwidth limit in KB/sec.""" + return self['snapshots.bwlimit.value'] + + @bw_limit.setter + def bw_limit(self, limit_kb_sec: int) -> None: + self['snapshots.bwlimit.value'] = limit_kb_sec + + @property + def no_snapshot_on_battery(self) -> bool: + """Don't take snapshots if the Computer runs on battery.""" + return self['snapshots.no_on_battery'] + + @no_snapshot_on_battery.setter + def no_snapshot_on_battery(self, enable: bool) -> None: + self['snapshots.no_on_battery'] = enable + + @property + def preserve_alc(self) -> bool: + """Preserve Access Control Lists (ACL). The source and destination + systems must have compatible ACL entries for this option to work + properly. + """ + return self['snapshots.preserve_acl'] + + @preserve_alc.setter + def preserve_alc(self, preserve: bool) -> None: + self['snapshots.preserve_acl'] = preserve + + @property + def preserve_xattr(self) -> bool: + """Preserve extended attributes (xattr).""" + return self['snapshots.preserve_xattr'] + + @preserve_xattr.setter + def preserve_xattr(self, preserve: bool) -> None: + """Preserve extended attributes (xattr).""" + self['snapshots.preserve_xattr'] = preserve + + @property + def copy_unsafe_links(self) -> bool: + """This tells rsync to copy the referent of symbolic links that point + outside the copied tree. Absolute symlinks are also treated like + ordinary files.""" + return self['snapshots.copy_unsafe_links'] + + @copy_unsafe_links.setter + def copy_unsafe_links(self, enable: bool) -> None: + self['snapshots.copy_unsafe_links'] = enable + + @property + def copy_links(self) -> bool: + """When symlinks are encountered, the item that they point to (the + reference) is copied, rather than the symlink. + """ + return self['snapshots.copy_links'] + + @copy_links.setter + def copy_links(self, enable: bool) -> None: + self['snapshots.copy_links'] = enable + + @property + def one_file_system(self) -> bool: + """Use rsync's "--one-file-system" to avoid crossing filesystem + boundaries when recursing. + """ + return self['snapshots.one_file_system'] + + @one_file_system.setter + def one_file_system(self, enable: bool) -> None: + self['snapshots.one_file_system'] = enable + + @property + def rsync_options_enabled(self) -> bool: + """Past additional options to rsync""" + return self['snapshots.rsync_options.enabled'] + + @rsync_options_enabled.setter + def rsync_options_enabled(self, enable: bool) -> None: + self['snapshots.rsync_options.enabled'] = enable + + @property + def rsync_options(self) -> str: + """Rsync options. Options must be quoted.""" + return self['snapshots.rsync_options.value'] + + @rsync_options.setter + def rsync_options(self, options: str) -> None: + self['snapshots.rsync_options.value'] = options + + @property + def ssh_prefix_enabled(self) -> bool: + """Add prefix to every command which run through SSH on remote host.""" + return self['snapshots.ssh.prefix.enabled'] + + @ssh_prefix_enabled.setter + def ssh_prefix_enabled(self, enable: bool) -> None: + self['snapshots.ssh.prefix.enabled'] = enable + + @property + def ssh_prefix(self) -> str: + """Prefix to run before every command on remote host. Variables need to + be escaped with \\\\$FOO. This doesn't touch rsync. So to add a prefix + for rsync use \\fIprofile.snapshots.rsync_options.value\\fR with + --rsync-path="FOO=bar:\\\\$FOO /usr/bin/rsync" + """ + return self['snapshots.ssh.prefix.value'] + + @ssh_prefix.setter + def ssh_prefix(self, prefix: str) -> None: + self['snapshots.ssh.prefix.value'] = prefix + + @property + def continue_on_errors(self) -> bool: + """Continue on errors. This will keep incomplete snapshots rather than + deleting and start over again.""" + return self['snapshots.continue_on_errors'] + + @continue_on_errors.setter + def continue_on_errors(self, enable: bool) -> None: + self['snapshots.continue_on_errors'] = enable + + @property + def use_checksum(self) -> bool: + """Use checksum to detect changes rather than size + time.""" + return self['snapshots.use_checksum'] + + @use_checksum.setter + def use_checksum(self, enable: bool) -> None: + self['snapshots.use_checksum'] = enable + + @property + def log_level(self) -> int: + """Log level used during takeSnapshot.\n1 = Error\n2 = Changes\n3 = + Info. + { 'values': '1-3' } + """ + return self['snapshots.log_level'] + + @log_level.setter + def log_level(self, level: int) -> None: + self['snapshots.log_level'] = level + + @property + def take_snapshot_regardless_of_changes(self) -> bool: + """Create a new snapshot regardless if there were changes or not.""" + return self['snapshots.take_snapshot_regardless_of_changes'] + + @take_snapshot_regardless_of_changes.setter + def take_snapshot_regardless_of_changes(self, enable: bool) -> None: + self['snapshots.take_snapshot_regardless_of_changes'] = enable + + @property + def global_flock(self) -> bool: + """Prevent multiple snapshots (from different profiles or users) to be + run at the same time. + """ + return self['global.use_flock'] + + @global_flock.setter + def global_flock(self, enable: bool) -> None: + self['global.use_flock'] = enable + + +class Konfig(metaclass=singleton.Singleton): + """Manage configuration data for Back In Time. + + Dev note: + + That class is a replacement for the `config.Config` class. + """ + + _DEFAULT_VALUES = { + 'global.hash_collision': 0, + 'global.language': '', + 'global.use_flock': False, + 'internal.manual_starts_countdown': 10, + } + + _DEFAULT_SECTION = 'bit' + + def __init__(self, buffer: Optional[TextIOWrapper, StringIO] = None): + """Constructor. + + Args: + buffer: An open text-file handle or a string buffer ready to read. + + Note: That method is executed only once because `Konfig` is a + singleton. + """ + if buffer: + self.load(buffer) + else: + self._conf = {} + + # Names and IDs of profiles + # Extract all relevant lines of format 'profile*.name=*' + name_items = filter( + lambda val: + val[0].startswith('profile') and val[0].endswith('.name'), + self._conf.items() + ) + self._profiles = { + name: int(pid.replace('profile', '').replace('.name', '')) + for pid, name in name_items + } + + def __getitem__(self, key: str) -> Any: + try: + return self._conf[key] + except KeyError: + return self._DEFAULT_VALUES[key] + + def __setitem__(self, key: str, val: Any) -> None: + self._conf[key] = val + + def __delitem__(self, key: str) -> None: + self._config_parser.remove_option(self._DEFAULT_SECTION, key) + + def profile(self, name_or_id: Union[str, int]) -> Profile: + """Return a `Profile` object related to the given name or id. + + Args: + name_or_id: A name or an numeric id of a snapshot profile. + + Raises: + KeyError: If no corresponding profile exists. + """ + if isinstance(name_or_id, int): + profile_id = name_or_id + else: + profile_id = self._profiles[name_or_id] + + return Profile(profile_id=profile_id, config=self) + + @property + def profile_names(self) -> list[str]: + """List of profile names.""" + return list(self._profiles.keys()) + + @property + def profile_ids(self) -> list[int]: + """List of numerical profile ids.""" + return list(self._profiles.values()) + + def load(self, buffer: Union[TextIOWrapper, StringIO]): + """Load configuration from file like object. + + Args: + buffer: An open text-file handle or a string buffer ready to read. + """ + + self._config_parser = configparser.ConfigParser( + interpolation=None, + defaults={'profile1.name': _('Main profile')}) + + # raw content + content = buffer.read() + + # Add section header to make it a real INI file + self._config_parser.read_string( + f'[{self._DEFAULT_SECTION}]\n{content}') + + # The one and only main section + self._conf = self._config_parser[self._DEFAULT_SECTION] + + def save(self, buffer: TextIOWrapper): + """Store configuraton to the config file.""" + + raise NotImplementedError('Prevent overwritting real config data.') + + # tmp_io_buffer = StringIO() + # self._config_parser.write(tmp_io_buffer) + # tmp_io_buffer.seek(0) + + # # Write to file without section header + # # Discard unwanted first line + # tmp_io_buffer.readline() + # handle.write(tmp_io_buffer.read()) + + @property + def hash_collision(self) -> int: + """Internal value used to prevent hash collisions on mountpoints. + Do not change this. + + { + 'values': '0-99999', + 'default': 0, + } + """ + return self['global.hash_collision'] + + @hash_collision.setter + def hash_collision(self, val: int) -> None: + self['global.hash_collision'] = val + + @property + def language(self) -> str: + """Language code (ISO 639) used to translate the user interface. If + empty the operating systems current local is used. If 'en' the + translation is not active and the original English source strings are + used. It is the same if the value is unknown. + { + 'values': 'ISO 639 language codes', + 'type': str + } + """ + return self['global.language'] + + @language.setter + def language(self, lang: str) -> None: + self['global.language'] = lang + + @property + def global_flock(self) -> bool: + """Prevent multiple snapshots (from different profiles or users) to be + run at the same time. + { + 'values': 'true|false', + 'default': 'false', + 'type': bool + } + """ + return self['global.use_flock'] + + @global_flock.setter + def global_flock(self, value: bool) -> None: + self['global.use_flock'] = value + + @property + def manual_starts_countdown(self) -> int: # pylint: disable=C0116 + # Countdown value about how often the users started the Back In Time + # GUI. + + # It is an internal variable not meant to be used or manipulated be the + # users. At the end of the countown the + # :py:class:`ApproachTranslatorDialog` is presented to the user. + return self['internal.manual_starts_countdown'] + + def decrement_manual_starts_countdown(self): + """Counts down to -1. + + See `manual_starts_countdown()` for details. + """ + val = self.manual_starts_countdown + + if val > -1: + self['internal.manual_starts_countdown'] = val - 1 + + +def config_file_path() -> Path: + """Return the config file path. + + Could be moved into backintime.py. sys.argv (--config) needs to be + considered. + """ + xdg_config = os.environ.get('XDG_CONFIG_HOME', Path.home() / '.config') + path = Path(xdg_config) / 'backintime' / 'config' + + logger.debug(f'Config path: {path}') + + return path + + +if __name__ == '__main__': + # Empty in-memory config file + # k = Konfig(StringIO()) + + k = Konfig() + print(k) + print(k._conf) # pylint: disable=protected-access + + # Regular config file + with config_file_path().open('r', encoding='utf-8') as handle: + k = Konfig() + k.load(handle) + + print(k) + print(k._conf) # pylint: disable=protected-access + + print(f'{k.profile_names=}') + print(f'{k.profile_ids=}') + print(f'{k.hash_collision=}') + print(f'{k.language=}') + print(f'{k.global_flock=}') + + p = k.profile(2) + print(f'{p.snapshots_mode=}') + p.snapshots_mode = 'ssh' + print(f'{p.snapshots_mode=}') + print(f'{p.include=}') + + p = k.profile(8) + print(f'{p.include=}') + + p = k.profile(9) + print(f'{p.include=}') + print(f'{p.exclude=}') + + p.include = [('foo', 0), ('bar', 1)] + print(f'{p.include=}') diff --git a/common/man/C/backintime-config.1 b/common/man/C/backintime-config.1 index fd595fbec..cd6bde9e0 100644 --- a/common/man/C/backintime-config.1 +++ b/common/man/C/backintime-config.1 @@ -1,15 +1,12 @@ .TH backintime-config 1 "November 2024" "version 1.6.0-dev.a5c44451" "USER COMMANDS" .SH NAME -config \- BackInTime configuration files. +config \- Back In Time configuration file. .SH SYNOPSIS ~/.config/backintime/config .br /etc/backintime/config .SH DESCRIPTION -Back In Time was developed as pure GUI program and so most functions are only -usable with backintime-qt. But it is possible to use -Back In Time e.g. on a headless server. You have to create the configuration file -(~/.config/backintime/config) manually. Look inside /usr/share/doc/backintime\-common/examples/ for examples. +Back In Time was developed as pure GUI program and so most functions are only usable with \fBbackintime-qt\fR. But it is possible to use Back In Time e.g. on a headless server. You have to create the configuration file (~/.config/backintime/config) manually. Look inside /usr/share/doc/backintime\-common/examples/ for examples. .PP The configuration file has the following format: .br @@ -30,244 +27,200 @@ Default: 0 .IP "\fIglobal.language\fR" 6 .RS -Type: str Allowed Values: text +Type: str Allowed Values: ISO 639 language codes .br Language code (ISO 639) used to translate the user interface. If empty the operating systems current local is used. If 'en' the translation is not active and the original English source strings are used. It is the same if the value is unknown. .PP -Default: '' +Default: .RE .IP "\fIglobal.use_flock\fR" 6 .RS Type: bool Allowed Values: true|false .br -Prevent multiple snapshots (from different profiles or users) to be run at the same time +Prevent multiple snapshots (from different profiles or users) to be run at the same time. .PP Default: false .RE -.IP "\fIprofile.name\fR" 6 +.IP "\fIprofile.snapshots.mode\fR" 6 .RS -Type: str Allowed Values: text +Type: str Allowed Values: local|local_encfs|ssh|ssh_encfs .br -Name of this profile. +Use mode (or backend) for this snapshot. Look at 'man backintime' section 'Modes'. .PP -Default: Main profile +Default: local .RE -.IP "\fIprofile.schedule.custom_time\fR" 6 +.IP "\fIprofile.snapshots.path\fR" 6 .RS -Type: str Allowed Values: comma separated int (8,12,18,23) or */3 +Type: str Allowed Values: absolute path .br -Custom hours for cronjob. Only valid for \fIprofile.schedule.mode\fR = 19 +Where to save snapshots in mode 'local'. This path must contain a folderstructure like 'backintime///'. .PP -Default: 8,12,18,23 + .RE -.IP "\fIprofile.schedule.day\fR" 6 +.IP "\fIprofile.snapshots.path.host\fR" 6 .RS -Type: int Allowed Values: 1-28 +Type: str Allowed Values: local hostname .br -Which day of month the cronjob should run? Only valid for \fIprofile.schedule.mode\fR >= 40 +Set Host for snapshot path. .PP -Default: 1 + .RE -.IP "\fIprofile.schedule.debug\fR" 6 +.IP "\fIprofile.snapshots.path.user\fR" 6 .RS -Type: bool Allowed Values: true|false +Type: str Allowed Values: local username .br -Enable debug output to system log for schedule mode. +Set User for snapshot path. .PP -Default: false + .RE -.IP "\fIprofile.schedule.mode\fR" 6 +.IP "\fIprofile.snapshots.path.profile\fR" 6 .RS -Type: int Allowed Values: 0|1|2|4|7|10|12|14|16|18|19|20|25|27|30|40|80 -.br -Which schedule used for crontab. The crontab entry will be generated with 'backintime check-config'. -.br - 0 = Disabled -.br - 1 = at every boot -.br - 2 = every 5 minute -.br - 4 = every 10 minute -.br - 7 = every 30 minute -.br -10 = every hour -.br -12 = every 2 hours -.br -14 = every 4 hours -.br -16 = every 6 hours -.br -18 = every 12 hours -.br -19 = custom defined hours -.br -20 = every day -.br -25 = daily anacron -.br -27 = when drive get connected -.br -30 = every week -.br -40 = every month +Type: str Allowed Values: 1-99999 .br -80 = every year +Set Profile-ID for snapshot path .PP -Default: 0 +Default: current Profile-ID .RE -.IP "\fIprofile.schedule.repeatedly.period\fR" 6 +.IP "\fIprofile.snapshots.ssh.path\fR" 6 .RS -Type: int Allowed Values: 0-99999 +Type: str Allowed Values: absolute or relative path .br -How many units to wait between new snapshots with anacron? Only valid for \fIprofile.schedule.mode\fR = 25|27 +Snapshot path on remote host. If the path is relative (no leading '/') it will start from remote Users homedir. An empty path will be replaced with './'. .PP -Default: 1 + .RE -.IP "\fIprofile.schedule.repeatedly.unit\fR" 6 +.IP "\fIprofile.snapshots.ssh.host\fR" 6 .RS -Type: int Allowed Values: 10|20|30|40 -.br -Units to wait between new snapshots with anacron. -.br -10 = hours -.br -20 = days -.br -30 = weeks -.br -40 = months +Type: str Allowed Values: IP or domain address .br -Only valid for \fIprofile.schedule.mode\fR = 25|27 +Remote host used for mode 'ssh' and 'ssh_encfs'. .PP -Default: 20 + .RE -.IP "\fIprofile.schedule.time\fR" 6 +.IP "\fIprofile.snapshots.ssh.port\fR" 6 .RS -Type: int Allowed Values: 0-2400 +Type: int Allowed Values: 0-65535 .br -Position-coded number with the format "hhmm" to specify the hour and minute the cronjob should start (eg. 2015 means a quarter past 8pm). Leading zeros can be omitted (eg. 30 = 0030). Only valid for \fIprofile.schedule.mode\fR = 20 (daily), 30 (weekly), 40 (monthly) and 80 (yearly) +SSH Port on remote host. .PP -Default: 0 +Default: 22 .RE -.IP "\fIprofile.schedule.weekday\fR" 6 +.IP "\fIprofile.snapshots.ssh.user\fR" 6 .RS -Type: int Allowed Values: 1 = monday \- 7 = sunday +Type: str Allowed Values: text .br -Which day of week the cronjob should run? Only valid for \fIprofile.schedule.mode\fR = 30 +Remote SSH user. .PP -Default: 7 +Default: local users name .RE -.IP "\fIprofile.snapshots.backup_on_restore.enabled\fR" 6 +.IP "\fIprofile.snapshots.ssh.cipher\fR" 6 .RS -Type: bool Allowed Values: true|false +Type: str Allowed Values: default | aes192-cbc | aes256-cbc | aes128-ctr | aes192-ctr | aes256-ctr | arcfour | arcfour256 | arcfour128 | aes128-cbc | 3des-cbc | blowfish-cbc | cast128-cbc .br -Rename existing files before restore into FILE.backup.YYYYMMDD +Cipher that is used for encrypting the SSH tunnel. Depending on the environment (network bandwidth, cpu and hdd performance) a different cipher might be faster. .PP -Default: true + .RE -.IP "\fIprofile.snapshots.bwlimit.enabled\fR" 6 +.IP "\fIprofile.snapshots.ssh.private_key_file\fR" 6 .RS -Type: bool Allowed Values: true|false +Type: str Allowed Values: absolute path to private key file .br -Limit rsync bandwidth usage over network. Use this with mode SSH. For mode Local you should rather use ionice. +Private key file used for password-less authentication on remote host. .PP -Default: false + .RE -.IP "\fIprofile.snapshots.bwlimit.value\fR" 6 +.IP "\fIprofile.snapshots.ssh.proxy_host\fR" 6 .RS -Type: int Allowed Values: 0-99999 +Type: str Allowed Values: IP or domain address .br -Bandwidth limit in KB/sec. +Proxy host (or jump host) used to connect to remote host. .PP -Default: 3000 + .RE -.IP "\fIprofile.snapshots.continue_on_errors\fR" 6 +.IP "\fIprofile.snapshots.ssh.proxy_port\fR" 6 .RS -Type: bool Allowed Values: true|false +Type: int Allowed Values: 0-65535 .br -Continue on errors. This will keep incomplete snapshots rather than deleting and start over again. +Port of SSH proxy (jump) host used to connect to remote host. .PP -Default: true +Default: 22 .RE -.IP "\fIprofile.snapshots.copy_links\fR" 6 +.IP "\fIprofile.snapshots.ssh.proxy_user\fR" 6 .RS -Type: bool Allowed Values: true|false +Type: str Allowed Values: text .br -When symlinks are encountered, the item that they point to (the reference) is copied, rather than the symlink. +SSH user at proxy (jump) host. .PP -Default: false +Default: local users name .RE -.IP "\fIprofile.snapshots.copy_unsafe_links\fR" 6 +.IP "\fIprofile.snapshots.ssh.max_arg_length\fR" 6 .RS -Type: bool Allowed Values: true|false +Type: int Allowed Values: 0, >700 .br -This tells rsync to copy the referent of symbolic links that point outside the copied tree. Absolute symlinks are also treated like ordinary files. +Maximum command length of commands run on remote host. This can be tested for all ssh profiles in the configuration with 'python3 /usr/share/backintime/common/sshMaxArg.py LENGTH'. The value '0' means unlimited length. .PP -Default: false + .RE -.IP "\fIprofile.snapshots.cron.ionice\fR" 6 +.IP "\fIprofile.snapshots.ssh.check_commands\fR" 6 .RS Type: bool Allowed Values: true|false .br -Run cronjobs with 'ionice \-c2 \-n7'. This will give BackInTime the lowest IO bandwidth priority to not interrupt any other working process. +Check if all commands (used during takeSnapshot) work like expected on the remote host. .PP -Default: true + .RE -.IP "\fIprofile.snapshots.cron.nice\fR" 6 +.IP "\fIprofile.snapshots.ssh.check_ping\fR" 6 .RS Type: bool Allowed Values: true|false .br -Run cronjobs with 'nice \-n19'. This will give BackInTime the lowest CPU priority to not interrupt any other working process. +Check if the remote host is available before trying to mount. .PP -Default: true + .RE -.IP "\fIprofile.snapshots.cron.redirect_stderr\fR" 6 +.IP "\fIprofile.snapshots.local_encfs.path\fR" 6 .RS -Type: bool Allowed Values: true|false +Type: Path Allowed Values: absolute path .br -redirect stderr to /dev/null in cronjobs +Where to save snapshots in mode 'local_encfs'. .PP -Default: False + .RE -.IP "\fIprofile.snapshots.cron.redirect_stdout\fR" 6 +.IP "\fIprofile.snapshots.password.save\fR" 6 .RS Type: bool Allowed Values: true|false .br -redirect stdout to /dev/null in cronjobs +Save password to system keyring (gnome-keyring or kwallet). .PP -Default: true + .RE -.IP "\fIprofile.snapshots.dont_remove_named_snapshots\fR" 6 +.IP "\fIprofile.snapshots.password.use_cache\fR" 6 .RS -Type: bool Allowed Values: true|false +Type: None Allowed Values: true|false .br -Keep snapshots with names during smart_remove. +Cache password in RAM so it can be read by cronjobs. Security issue: root might be able to read that password, too. .PP -Default: true +Default: see #1855 .RE .IP "\fIprofile.snapshots.exclude.bysize.enabled\fR" 6 @@ -276,7 +229,7 @@ Type: bool Allowed Values: true|false .br Enable exclude files by size. .PP -Default: false + .RE .IP "\fIprofile.snapshots.exclude.bysize.value\fR" 6 @@ -285,453 +238,403 @@ Type: int Allowed Values: 0-99999 .br Exclude files bigger than value in MiB. With 'Full rsync mode' disabled this will only affect new files because for rsync this is a transfer option, not an exclude option. So big files that has been backed up before will remain in snapshots even if they had changed. .PP -Default: 500 + .RE -.IP "\fIprofile.snapshots.exclude..value\fR" 6 +.IP "\fIprofile.schedule.mode\fR" 6 .RS -Type: str Allowed Values: file, folder or pattern (relative or absolute) +Type: int Allowed Values: 0|1|2|4|7|10|12|14|16|18|19|20|25|27|30|40|80 .br -Exclude this file or folder. must be a counter starting with 1 +Which schedule used for crontab. The crontab entry will be generated with 'backintime check-config'. 0 = Disabled 1 = at every boot 2 = every 5 minute 4 = every 10 minute 7 = every 30 minute 10 = every hour 12 = every 2 hours 14 = every 4 hours 16 = every 6 hours 18 = every 12 hours 19 = custom defined hours 20 = every day 25 = daily anacron 27 = when drive get connected 30 = every week 40 = every month 80 = every year .PP -Default: '' + .RE -.IP "\fIprofile.snapshots.exclude.size\fR" 6 +.IP "\fIprofile.schedule.debug\fR" 6 .RS -Type: int Allowed Values: 0-99999 +Type: bool Allowed Values: true|false .br -Quantity of profile.snapshots.exclude. entries. +Enable debug output to system log for schedule mode. .PP -Default: \-1 + .RE -.IP "\fIprofile.snapshots.include..type\fR" 6 +.IP "\fIprofile.schedule.time\fR" 6 .RS -Type: int Allowed Values: 0|1 +Type: int Allowed Values: 0-2400 .br -Specify if \fIprofile.snapshots.include..value\fR is a folder (0) or a file (1). +Position-coded number with the format "hhmm" to specify the hour and minute the cronjob should start (eg. 2015 means a quarter past 8pm). Leading zeros can be omitted (eg. 30 = 0030). Only valid for \fIprofile.schedule.mode\fR = 20 (daily), 30 (weekly), 40 (monthly) and 80 (yearly). .PP -Default: 0 + .RE -.IP "\fIprofile.snapshots.include..value\fR" 6 +.IP "\fIprofile.schedule.day\fR" 6 .RS -Type: str Allowed Values: absolute path +Type: int Allowed Values: 1-28 .br -Include this file or folder. must be a counter starting with 1 +Which day of month the cronjob should run? Only valid for \fIprofile.schedule.mode\fR >= 40. .PP -Default: '' + .RE -.IP "\fIprofile.snapshots.include.size\fR" 6 +.IP "\fIprofile.schedule.weekday\fR" 6 .RS -Type: int Allowed Values: 0-99999 +Type: int Allowed Values: 1 (monday) to 7 (sunday) .br -Quantity of profile.snapshots.include. entries. +Which day of week the cronjob should run? Only valid for \fIprofile.schedule.mode\fR = 30. .PP -Default: \-1 + .RE -.IP "\fIprofile.snapshots.keep_only_one_snapshot.enabled\fR" 6 +.IP "\fIprofile.schedule.custom_time\fR" 6 .RS -Type: bool Allowed Values: true|false +Type: str Allowed Values: comma separated int (8,12,18,23) or */3;8,12,18,23 .br -NOT YET IMPLEMENTED. Remove all snapshots but one. +Custom hours for cronjob. Only valid for \fIprofile.schedule.mode\fR = 19 .PP -Default: false + .RE -.IP "\fIprofile.snapshots.local.nocache\fR" 6 +.IP "\fIprofile.schedule.repeatedly.period\fR" 6 .RS -Type: bool Allowed Values: true|false +Type: int Allowed Values: 0-99999 .br -Run rsync on local machine with 'nocache'. This will prevent files from being cached in memory. +How many units to wait between new snapshots with anacron? Only valid for \fIprofile.schedule.mode\fR = 25|27. .PP -Default: false + .RE -.IP "\fIprofile.snapshots.local_encfs.path\fR" 6 +.IP "\fIprofile.schedule.repeatedly.unit\fR" 6 .RS -Type: str Allowed Values: absolute path +Type: int Allowed Values: 10|20|30|40 .br -Where to save snapshots in mode 'local_encfs'. +Units to wait between new snapshots with anacron. 10 = hours 20 = days 30 = weeks 40 = months Only valid for \fIprofile.schedule.mode\fR = 25|27; .PP -Default: '' + .RE -.IP "\fIprofile.snapshots.log_level\fR" 6 +.IP "\fIprofile.snapshots.remove_old_snapshots.enabled\fR" 6 .RS -Type: int Allowed Values: 1-3 -.br -Log level used during takeSnapshot. -.br -1 = Error -.br -2 = Changes +Type: bool Allowed Values: true|false .br -3 = Info +Remove all snapshots older than value + unit. .PP -Default: 3 + .RE -.IP "\fIprofile.snapshots.min_free_inodes.enabled\fR" 6 +.IP "\fIprofile.snapshots.remove_old_snapshots.value\fR" 6 .RS -Type: bool Allowed Values: true|false +Type: int Allowed Values: 0-99999 .br -Remove snapshots until \fIprofile.snapshots.min_free_inodes.value\fR free inodes in % is reached. +Snapshots older than this times units will be removed. .PP -Default: false + .RE -.IP "\fIprofile.snapshots.min_free_inodes.value\fR" 6 +.IP "\fIprofile.snapshots.remove_old_snapshots.unit\fR" 6 .RS -Type: int Allowed Values: 1-15 +Type: TimeUnit Allowed Values: 20|30|80 .br -Keep at least value % free inodes. +Time unit to use to calculate removing of old snapshots. 20 = days; 30 = weeks; 80 = years .PP -Default: 2 + .RE .IP "\fIprofile.snapshots.min_free_space.enabled\fR" 6 .RS Type: bool Allowed Values: true|false .br -Remove snapshots until \fIprofile.snapshots.min_free_space.value\fR free space is reached. +Remove snapshots until \fIprofile.snapshots.min_free_space. value\fR free space is reached. .PP -Default: false -.RE -.IP "\fIprofile.snapshots.min_free_space.unit\fR" 6 -.RS -Type: int Allowed Values: 10|20 -.br -10 = MB -.br -20 = GB -.PP -Default: 20 .RE .IP "\fIprofile.snapshots.min_free_space.value\fR" 6 .RS -Type: int Allowed Values: 1-99999 +Type: int Allowed Values: 0-99999 .br Keep at least value + unit free space. .PP -Default: 1 + .RE -.IP "\fIprofile.snapshots.mode\fR" 6 +.IP "\fIprofile.snapshots.min_free_space.unit\fR" 6 .RS -Type: str Allowed Values: local|local_encfs|ssh|ssh_encfs +Type: StorageSizeUnitAllowed Values: 10|20 .br - Use mode (or backend) for this snapshot. Look at 'man backintime' section 'Modes'. +10 = MB 20 = GB .PP -Default: local + .RE -.IP "\fIprofile.snapshots..password.save\fR" 6 +.IP "\fIprofile.snapshots.min_free_inodes.enabled\fR" 6 .RS Type: bool Allowed Values: true|false .br -Save password to system keyring (gnome-keyring or kwallet). must be the same as \fIprofile.snapshots.mode\fR +Remove snapshots until \fIprofile.snapshots.min_free_inodes.value\fR free inodes in % is reached. .PP -Default: false + .RE -.IP "\fIprofile.snapshots..password.use_cache\fR" 6 +.IP "\fIprofile.snapshots.min_free_inodes.value\fR" 6 .RS -Type: bool Allowed Values: true|false +Type: int Allowed Values: 1-15 .br -Cache password in RAM so it can be read by cronjobs. Security issue: root might be able to read that password, too. must be the same as \fIprofile.snapshots.mode\fR +Keep at least value % free inodes. .PP -Default: true if home is not encrypted + .RE -.IP "\fIprofile.snapshots.no_on_battery\fR" 6 +.IP "\fIprofile.snapshots.dont_remove_named_snapshots\fR" 6 .RS Type: bool Allowed Values: true|false .br -Don't take snapshots if the Computer runs on battery. +Keep snapshots with names during smart_remove. .PP -Default: false + .RE -.IP "\fIprofile.snapshots.notify.enabled\fR" 6 +.IP "\fIprofile.snapshots.smart_remove\fR" 6 .RS Type: bool Allowed Values: true|false .br -Display notifications (errors, warnings) through libnotify. +Run smart_remove to clean up old snapshots after a new snapshot was created. .PP -Default: true + .RE -.IP "\fIprofile.snapshots.one_file_system\fR" 6 +.IP "\fIprofile.snapshots.smart_remove.keep_all\fR" 6 .RS -Type: bool Allowed Values: true|false +Type: int Allowed Values: 0-99999 .br -Use rsync's "--one-file-system" to avoid crossing filesystem boundaries when recursing. +Keep all snapshots for X days. .PP -Default: false + .RE -.IP "\fIprofile.snapshots.path\fR" 6 +.IP "\fIprofile.snapshots.smart_remove.keep_one_per_day\fR" 6 .RS -Type: str Allowed Values: absolute path +Type: int Allowed Values: 0-99999 .br -Where to save snapshots in mode 'local'. This path must contain a folderstructure like 'backintime///' +Keep one snapshot per day for X days. .PP -Default: '' + .RE -.IP "\fIprofile.snapshots.path.host\fR" 6 +.IP "\fIprofile.snapshots.smart_remove.keep_one_per_week\fR" 6 .RS -Type: str Allowed Values: text +Type: int Allowed Values: 0-99999 .br -Set Host for snapshot path +Keep one snapshot per week for X weeks. .PP -Default: local hostname + .RE -.IP "\fIprofile.snapshots.path.profile\fR" 6 +.IP "\fIprofile.snapshots.smart_remove.keep_one_per_month\fR" 6 .RS -Type: str Allowed Values: 1-99999 +Type: int Allowed Values: 0-99999 .br -Set Profile-ID for snapshot path +Keep one snapshot per month for X months. .PP -Default: current Profile-ID + .RE -.IP "\fIprofile.snapshots.path.user\fR" 6 +.IP "\fIprofile.snapshots.smart_remove.run_remote_in_background\fR" 6 .RS -Type: str Allowed Values: text +Type: bool Allowed Values: true|false .br -Set User for snapshot path +If using modes SSH or SSH-encrypted, run smart_remove in background on remote machine .PP -Default: local username + .RE -.IP "\fIprofile.snapshots.path.uuid\fR" 6 +.IP "\fIprofile.snapshots.notify.enabled\fR" 6 .RS -Type: str Allowed Values: text +Type: bool Allowed Values: true|false .br -Devices uuid used to automatically set up udev rule if the drive is not connected. +Display notifications (errors, warnings) through libnotify or DBUS. .PP -Default: '' + .RE -.IP "\fIprofile.snapshots.preserve_acl\fR" 6 +.IP "\fIprofile.snapshots.backup_on_restore.enabled\fR" 6 .RS Type: bool Allowed Values: true|false .br -Preserve ACL. The source and destination systems must have compatible ACL entries for this option to work properly. +Rename existing files before restore into FILE.backup.YYYYMMDD .PP -Default: false + .RE -.IP "\fIprofile.snapshots.preserve_xattr\fR" 6 +.IP "\fIprofile.snapshots.cron.nice\fR" 6 .RS Type: bool Allowed Values: true|false .br -Preserve extended attributes (xattr). +Run cronjobs with nice-Value 19. This will give Back In Time the lowest CPU priority to not interrupt any other working process. .PP -Default: false + .RE -.IP "\fIprofile.snapshots.remove_old_snapshots.enabled\fR" 6 +.IP "\fIprofile.snapshots.cron.ionice\fR" 6 .RS Type: bool Allowed Values: true|false .br -Remove all snapshots older than value + unit +Run cronjobs with 'ionice' and class 2 and level 7. This will give Back In Time the lowest IO bandwidth priority to not interrupt any other working process. .PP -Default: true + .RE -.IP "\fIprofile.snapshots.remove_old_snapshots.unit\fR" 6 +.IP "\fIprofile.snapshots.user_backup.ionice\fR" 6 .RS -Type: int Allowed Values: 20|30|80 -.br -20 = days -.br -30 = weeks +Type: bool Allowed Values: true|false .br -80 = years +Run Back In Time with 'ionice' and class 2 and level 7 when taking a manual snapshot. This will give Back In Time the lowest IO bandwidth priority to not interrupt any other working process. .PP -Default: 80 + .RE -.IP "\fIprofile.snapshots.remove_old_snapshots.value\fR" 6 +.IP "\fIprofile.snapshots.ssh.nice\fR" 6 .RS -Type: int Allowed Values: 0-99999 +Type: bool Allowed Values: true|false .br -Snapshots older than this times units will be removed +Run rsync and other commands on remote host with 'nice' value 19. .PP -Default: 10 + .RE -.IP "\fIprofile.snapshots.rsync_options.enabled\fR" 6 +.IP "\fIprofile.snapshots.ssh.ionice\fR" 6 .RS Type: bool Allowed Values: true|false .br -Past additional options to rsync +Run rsync and other commands on remote host with 'ionice' and class 2 and level 7. .PP -Default: false + .RE -.IP "\fIprofile.snapshots.rsync_options.value\fR" 6 +.IP "\fIprofile.snapshots.local.nocache\fR" 6 .RS -Type: str Allowed Values: text +Type: bool Allowed Values: true|false .br -rsync options. Options must be quoted e.g. \-\-exclude-from="/path/to/my exclude file" +Run rsync on local machine with 'nocache'. This will prevent files from being cached in memory. .PP -Default: '' + .RE -.IP "\fIprofile.snapshots.smart_remove\fR" 6 +.IP "\fIprofile.snapshots.ssh.nocache\fR" 6 .RS Type: bool Allowed Values: true|false .br -Run smart_remove to clean up old snapshots after a new snapshot was created. +Run rsync on remote host with 'nocache'. This will prevent files from being cached in memory. .PP -Default: false + .RE -.IP "\fIprofile.snapshots.smart_remove.keep_all\fR" 6 +.IP "\fIprofile.snapshots.cron.redirect_stdout\fR" 6 .RS -Type: int Allowed Values: 0-99999 +Type: bool Allowed Values: true|false .br -Keep all snapshots for X days. +Redirect stdout to /dev/null in cronjobs. .PP -Default: 2 + .RE -.IP "\fIprofile.snapshots.smart_remove.keep_one_per_day\fR" 6 +.IP "\fIprofile.snapshots.cron.redirect_stderr\fR" 6 .RS -Type: int Allowed Values: 0-99999 +Type: bool Allowed Values: true|false .br -Keep one snapshot per day for X days. +Redirect stderr to /dev/null in cronjobs. .PP -Default: 7 + .RE -.IP "\fIprofile.snapshots.smart_remove.keep_one_per_month\fR" 6 +.IP "\fIprofile.snapshots.bwlimit.enabled\fR" 6 .RS -Type: int Allowed Values: 0-99999 +Type: bool Allowed Values: true|false .br -Keep one snapshot per month for X month. +Limit rsync bandwidth usage over network. Use this with mode SSH. For mode Local you should rather use ionice. .PP -Default: 24 + .RE -.IP "\fIprofile.snapshots.smart_remove.keep_one_per_week\fR" 6 +.IP "\fIprofile.snapshots.bwlimit.value\fR" 6 .RS Type: int Allowed Values: 0-99999 .br -Keep one snapshot per week for X weeks. +Bandwidth limit in KB/sec. .PP -Default: 4 + .RE -.IP "\fIprofile.snapshots.smart_remove.run_remote_in_background\fR" 6 +.IP "\fIprofile.snapshots.no_on_battery\fR" 6 .RS Type: bool Allowed Values: true|false .br -If using mode SSH or SSH-encrypted, run smart_remove in background on remote machine +Don't take snapshots if the Computer runs on battery. .PP -Default: false + .RE -.IP "\fIprofile.snapshots.ssh.check_commands\fR" 6 +.IP "\fIprofile.snapshots.preserve_acl\fR" 6 .RS Type: bool Allowed Values: true|false .br -Check if all commands (used during takeSnapshot) work like expected on the remote host. +Preserve Access Control Lists (ACL). The source and destination systems must have compatible ACL entries for this option to work properly. .PP -Default: true + .RE -.IP "\fIprofile.snapshots.ssh.check_ping\fR" 6 +.IP "\fIprofile.snapshots.preserve_xattr\fR" 6 .RS Type: bool Allowed Values: true|false .br -Check if the remote host is available before trying to mount. +Preserve extended attributes (xattr). .PP -Default: true -.RE -.IP "\fIprofile.snapshots.ssh.cipher\fR" 6 -.RS -Type: str Allowed Values: default | aes192-cbc | aes256-cbc | aes128-ctr | aes192-ctr | aes256-ctr | arcfour | arcfour256 | arcfour128 | aes128-cbc | 3des-cbc | blowfish-cbc | cast128-cbc -.br -Cipher that is used for encrypting the SSH tunnel. Depending on the environment (network bandwidth, cpu and hdd performance) a different cipher might be faster. -.PP -Default: default .RE -.IP "\fIprofile.snapshots.ssh.host\fR" 6 +.IP "\fIprofile.snapshots.copy_unsafe_links\fR" 6 .RS -Type: str Allowed Values: IP or domain address +Type: bool Allowed Values: true|false .br -Remote host used for mode 'ssh' and 'ssh_encfs'. +This tells rsync to copy the referent of symbolic links that point outside the copied tree. Absolute symlinks are also treated like ordinary files. .PP -Default: '' + .RE -.IP "\fIprofile.snapshots.ssh.ionice\fR" 6 +.IP "\fIprofile.snapshots.copy_links\fR" 6 .RS Type: bool Allowed Values: true|false .br -Run rsync and other commands on remote host with 'ionice \-c2 \-n7' +When symlinks are encountered, the item that they point to (the reference) is copied, rather than the symlink. .PP -Default: false -.RE -.IP "\fIprofile.snapshots.ssh.max_arg_length\fR" 6 -.RS -Type: int Allowed Values: 0, >700 -.br -Maximum command length of commands run on remote host. This can be tested for all ssh profiles in the configuration with 'python3 /usr/share/backintime/common/sshMaxArg.py LENGTH'. -.br -0 = unlimited -.PP -Default: 0 .RE -.IP "\fIprofile.snapshots.ssh.nice\fR" 6 +.IP "\fIprofile.snapshots.one_file_system\fR" 6 .RS Type: bool Allowed Values: true|false .br -Run rsync and other commands on remote host with 'nice \-n19' +Use rsync's "--one-file-system" to avoid crossing filesystem boundaries when recursing. .PP -Default: false + .RE -.IP "\fIprofile.snapshots.ssh.nocache\fR" 6 +.IP "\fIprofile.snapshots.rsync_options.enabled\fR" 6 .RS Type: bool Allowed Values: true|false .br -Run rsync on remote host with 'nocache'. This will prevent files from being cached in memory. +Past additional options to rsync .PP -Default: false -.RE -.IP "\fIprofile.snapshots.ssh.path\fR" 6 -.RS -Type: str Allowed Values: absolute or relative path -.br -Snapshot path on remote host. If the path is relative (no leading '/') it will start from remote Users homedir. An empty path will be replaced with './'. -.PP -Default: '' .RE -.IP "\fIprofile.snapshots.ssh.port\fR" 6 +.IP "\fIprofile.snapshots.rsync_options.value\fR" 6 .RS -Type: int Allowed Values: 0-65535 +Type: str Allowed Values: text .br -SSH Port on remote host. +Rsync options. Options must be quoted. .PP -Default: 22 + .RE .IP "\fIprofile.snapshots.ssh.prefix.enabled\fR" 6 @@ -740,7 +643,7 @@ Type: bool Allowed Values: true|false .br Add prefix to every command which run through SSH on remote host. .PP -Default: false + .RE .IP "\fIprofile.snapshots.ssh.prefix.value\fR" 6 @@ -749,52 +652,34 @@ Type: str Allowed Values: text .br Prefix to run before every command on remote host. Variables need to be escaped with \\$FOO. This doesn't touch rsync. So to add a prefix for rsync use \fIprofile.snapshots.rsync_options.value\fR with --rsync-path="FOO=bar:\\$FOO /usr/bin/rsync" .PP -Default: 'PATH=/opt/bin:/opt/sbin:\\$PATH' -.RE -.IP "\fIprofile.snapshots.ssh.private_key_file\fR" 6 -.RS -Type: str Allowed Values: absolute path to private key file -.br -Private key file used for password-less authentication on remote host. -.PP -Default: ~/.ssh/id_dsa .RE -.IP "\fIprofile.snapshots.ssh.proxy_host\fR" 6 +.IP "\fIprofile.snapshots.continue_on_errors\fR" 6 .RS -Type: str Allowed Values: text +Type: bool Allowed Values: true|false .br -Proxy host used to connect to remote host. +Continue on errors. This will keep incomplete snapshots rather than deleting and start over again. .PP -Default: IP or domain address -.RE -.IP "\fIprofile.snapshots.ssh.proxy_host_port\fR" 6 -.RS -Type: int Allowed Values: 0-65535 -.br -Proxy host port used to connect to remote host. -.PP -Default: 22 .RE -.IP "\fIprofile.snapshots.ssh.proxy_user\fR" 6 +.IP "\fIprofile.snapshots.use_checksum\fR" 6 .RS -Type: str Allowed Values: text +Type: bool Allowed Values: true|false .br -Remote SSH user +Use checksum to detect changes rather than size + time. .PP -Default: the local users name + .RE -.IP "\fIprofile.snapshots.ssh.user\fR" 6 +.IP "\fIprofile.snapshots.log_level\fR" 6 .RS -Type: str Allowed Values: text +Type: int Allowed Values: 1-3 .br -Remote SSH user +Log level used during takeSnapshot. 1 = Error 2 = Changes 3 = Info. .PP -Default: local users name + .RE .IP "\fIprofile.snapshots.take_snapshot_regardless_of_changes\fR" 6 @@ -803,56 +688,22 @@ Type: bool Allowed Values: true|false .br Create a new snapshot regardless if there were changes or not. .PP -Default: false -.RE -.IP "\fIprofile.snapshots.use_checksum\fR" 6 -.RS -Type: bool Allowed Values: true|false -.br -Use checksum to detect changes rather than size + time. -.PP -Default: false .RE -.IP "\fIprofile.snapshots.user_backup.ionice\fR" 6 +.IP "\fIprofile.global.use_flock\fR" 6 .RS Type: bool Allowed Values: true|false .br -Run BackInTime with 'ionice \-c2 \-n7' when taking a manual snapshot. This will give BackInTime the lowest IO bandwidth priority to not interrupt any other working process. +Prevent multiple snapshots (from different profiles or users) to be run at the same time. .PP -Default: false -.RE -.IP "\fIprofile.user_callback.no_logging\fR" 6 -.RS -Type: bool Allowed Values: true|false -.br -Do not catch std{out|err} from user-callback script. The script will only write to current TTY. Default is to catch std{out|err} and write it to syslog and TTY again. -.PP -Default: false .RE -.IP "\fIprofiles\fR" 6 -.RS -Type: str Allowed Values: int separated by colon (e.g. 1:3:4) -.br -All active Profiles ( in profile.snapshots...). -.PP -Default: 1 -.RE - -.IP "\fIprofiles.version\fR" 6 -.RS -Type: int Allowed Values: 1 -.br -Internal version of profiles config. -.PP -Default: 1 -.RE .SH SEE ALSO -backintime, backintime-qt. +.BR backintime (1), +.BR backintime-qt (1) .PP Back In Time also has a website: https://github.com/bit-team/backintime .SH AUTHOR -This manual page was written by BIT Team(). +This manual page was written by the Back In Time Team (). diff --git a/common/man/C/backintime-config.1.org b/common/man/C/backintime-config.1.org new file mode 100644 index 000000000..afd118b19 --- /dev/null +++ b/common/man/C/backintime-config.1.org @@ -0,0 +1,858 @@ +.TH backintime-config 1 "Aug 2024" "version 1.5.3-dev" "USER COMMANDS" +.SH NAME +config \- BackInTime configuration files. +.SH SYNOPSIS +~/.config/backintime/config +.br +/etc/backintime/config +.SH DESCRIPTION +Back In Time was developed as pure GUI program and so most functions are only +usable with backintime-qt. But it is possible to use +Back In Time e.g. on a headless server. You have to create the configuration file +(~/.config/backintime/config) manually. Look inside /usr/share/doc/backintime\-common/examples/ for examples. +.PP +The configuration file has the following format: +.br +keyword=arguments +.PP +Arguments don't need to be quoted. All characters are allowed except '='. +.PP +Run 'backintime check-config' to verify the configfile, create the snapshot folder and crontab entries. +.SH POSSIBLE KEYWORDS +.IP "\fIglobal.hash_collision\fR" 6 +.RS +Type: int Allowed Values: 0-99999 +.br +Internal value used to prevent hash collisions on mountpoints. Do not change this. +.PP +Default: 0 +.RE + +.IP "\fIglobal.language\fR" 6 +.RS +Type: str Allowed Values: text +.br +Language code (ISO 639) used to translate the user interface. If empty the operating systems current local is used. If 'en' the translation is not active and the original English source strings are used. It is the same if the value is unknown. +.PP +Default: '' +.RE + +.IP "\fIglobal.use_flock\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Prevent multiple snapshots (from different profiles or users) to be run at the same time +.PP +Default: false +.RE + +.IP "\fIprofile.name\fR" 6 +.RS +Type: str Allowed Values: text +.br +Name of this profile. +.PP +Default: Main profile +.RE + +.IP "\fIprofile.schedule.custom_time\fR" 6 +.RS +Type: str Allowed Values: comma separated int (8,12,18,23) or */3 +.br +Custom hours for cronjob. Only valid for \fIprofile.schedule.mode\fR = 19 +.PP +Default: 8,12,18,23 +.RE + +.IP "\fIprofile.schedule.day\fR" 6 +.RS +Type: int Allowed Values: 1-28 +.br +Which day of month the cronjob should run? Only valid for \fIprofile.schedule.mode\fR >= 40 +.PP +Default: 1 +.RE + +.IP "\fIprofile.schedule.debug\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Enable debug output to system log for schedule mode. +.PP +Default: false +.RE + +.IP "\fIprofile.schedule.mode\fR" 6 +.RS +Type: int Allowed Values: 0|1|2|4|7|10|12|14|16|18|19|20|25|27|30|40|80 +.br +Which schedule used for crontab. The crontab entry will be generated with 'backintime check-config'. +.br + 0 = Disabled +.br + 1 = at every boot +.br + 2 = every 5 minute +.br + 4 = every 10 minute +.br + 7 = every 30 minute +.br +10 = every hour +.br +12 = every 2 hours +.br +14 = every 4 hours +.br +16 = every 6 hours +.br +18 = every 12 hours +.br +19 = custom defined hours +.br +20 = every day +.br +25 = daily anacron +.br +27 = when drive get connected +.br +30 = every week +.br +40 = every month +.br +80 = every year +.PP +Default: 0 +.RE + +.IP "\fIprofile.schedule.repeatedly.period\fR" 6 +.RS +Type: int Allowed Values: 0-99999 +.br +How many units to wait between new snapshots with anacron? Only valid for \fIprofile.schedule.mode\fR = 25|27 +.PP +Default: 1 +.RE + +.IP "\fIprofile.schedule.repeatedly.unit\fR" 6 +.RS +Type: int Allowed Values: 10|20|30|40 +.br +Units to wait between new snapshots with anacron. +.br +10 = hours +.br +20 = days +.br +30 = weeks +.br +40 = months +.br +Only valid for \fIprofile.schedule.mode\fR = 25|27 +.PP +Default: 20 +.RE + +.IP "\fIprofile.schedule.time\fR" 6 +.RS +Type: int Allowed Values: 0-2400 +.br +Position-coded number with the format "hhmm" to specify the hour and minute the cronjob should start (eg. 2015 means a quarter past 8pm). Leading zeros can be omitted (eg. 30 = 0030). Only valid for \fIprofile.schedule.mode\fR = 20 (daily), 30 (weekly), 40 (monthly) and 80 (yearly) +.PP +Default: 0 +.RE + +.IP "\fIprofile.schedule.weekday\fR" 6 +.RS +Type: int Allowed Values: 1 = monday \- 7 = sunday +.br +Which day of week the cronjob should run? Only valid for \fIprofile.schedule.mode\fR = 30 +.PP +Default: 7 +.RE + +.IP "\fIprofile.snapshots.backup_on_restore.enabled\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Rename existing files before restore into FILE.backup.YYYYMMDD +.PP +Default: true +.RE + +.IP "\fIprofile.snapshots.bwlimit.enabled\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Limit rsync bandwidth usage over network. Use this with mode SSH. For mode Local you should rather use ionice. +.PP +Default: false +.RE + +.IP "\fIprofile.snapshots.bwlimit.value\fR" 6 +.RS +Type: int Allowed Values: 0-99999 +.br +Bandwidth limit in KB/sec. +.PP +Default: 3000 +.RE + +.IP "\fIprofile.snapshots.continue_on_errors\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Continue on errors. This will keep incomplete snapshots rather than deleting and start over again. +.PP +Default: true +.RE + +.IP "\fIprofile.snapshots.copy_links\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +When symlinks are encountered, the item that they point to (the reference) is copied, rather than the symlink. +.PP +Default: false +.RE + +.IP "\fIprofile.snapshots.copy_unsafe_links\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +This tells rsync to copy the referent of symbolic links that point outside the copied tree. Absolute symlinks are also treated like ordinary files. +.PP +Default: false +.RE + +.IP "\fIprofile.snapshots.cron.ionice\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Run cronjobs with 'ionice \-c2 \-n7'. This will give BackInTime the lowest IO bandwidth priority to not interrupt any other working process. +.PP +Default: true +.RE + +.IP "\fIprofile.snapshots.cron.nice\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Run cronjobs with 'nice \-n19'. This will give BackInTime the lowest CPU priority to not interrupt any other working process. +.PP +Default: true +.RE + +.IP "\fIprofile.snapshots.cron.redirect_stderr\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +redirect stderr to /dev/null in cronjobs +.PP +Default: False +.RE + +.IP "\fIprofile.snapshots.cron.redirect_stdout\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +redirect stdout to /dev/null in cronjobs +.PP +Default: true +.RE + +.IP "\fIprofile.snapshots.dont_remove_named_snapshots\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Keep snapshots with names during smart_remove. +.PP +Default: true +.RE + +.IP "\fIprofile.snapshots.exclude.bysize.enabled\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Enable exclude files by size. +.PP +Default: false +.RE + +.IP "\fIprofile.snapshots.exclude.bysize.value\fR" 6 +.RS +Type: int Allowed Values: 0-99999 +.br +Exclude files bigger than value in MiB. With 'Full rsync mode' disabled this will only affect new files because for rsync this is a transfer option, not an exclude option. So big files that has been backed up before will remain in snapshots even if they had changed. +.PP +Default: 500 +.RE + +.IP "\fIprofile.snapshots.exclude..value\fR" 6 +.RS +Type: str Allowed Values: file, folder or pattern (relative or absolute) +.br +Exclude this file or folder. must be a counter starting with 1 +.PP +Default: '' +.RE + +.IP "\fIprofile.snapshots.exclude.size\fR" 6 +.RS +Type: int Allowed Values: 0-99999 +.br +Quantity of profile.snapshots.exclude. entries. +.PP +Default: \-1 +.RE + +.IP "\fIprofile.snapshots.include..type\fR" 6 +.RS +Type: int Allowed Values: 0|1 +.br +Specify if \fIprofile.snapshots.include..value\fR is a folder (0) or a file (1). +.PP +Default: 0 +.RE + +.IP "\fIprofile.snapshots.include..value\fR" 6 +.RS +Type: str Allowed Values: absolute path +.br +Include this file or folder. must be a counter starting with 1 +.PP +Default: '' +.RE + +.IP "\fIprofile.snapshots.include.size\fR" 6 +.RS +Type: int Allowed Values: 0-99999 +.br +Quantity of profile.snapshots.include. entries. +.PP +Default: \-1 +.RE + +.IP "\fIprofile.snapshots.keep_only_one_snapshot.enabled\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +NOT YET IMPLEMENTED. Remove all snapshots but one. +.PP +Default: false +.RE + +.IP "\fIprofile.snapshots.local.nocache\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Run rsync on local machine with 'nocache'. This will prevent files from being cached in memory. +.PP +Default: false +.RE + +.IP "\fIprofile.snapshots.local_encfs.path\fR" 6 +.RS +Type: str Allowed Values: absolute path +.br +Where to save snapshots in mode 'local_encfs'. +.PP +Default: '' +.RE + +.IP "\fIprofile.snapshots.log_level\fR" 6 +.RS +Type: int Allowed Values: 1-3 +.br +Log level used during takeSnapshot. +.br +1 = Error +.br +2 = Changes +.br +3 = Info +.PP +Default: 3 +.RE + +.IP "\fIprofile.snapshots.min_free_inodes.enabled\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Remove snapshots until \fIprofile.snapshots.min_free_inodes.value\fR free inodes in % is reached. +.PP +Default: true +.RE + +.IP "\fIprofile.snapshots.min_free_inodes.value\fR" 6 +.RS +Type: int Allowed Values: 1-15 +.br +Keep at least value % free inodes. +.PP +Default: 2 +.RE + +.IP "\fIprofile.snapshots.min_free_space.enabled\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Remove snapshots until \fIprofile.snapshots.min_free_space.value\fR free space is reached. +.PP +Default: true +.RE + +.IP "\fIprofile.snapshots.min_free_space.unit\fR" 6 +.RS +Type: int Allowed Values: 10|20 +.br +10 = MB +.br +20 = GB +.PP +Default: 20 +.RE + +.IP "\fIprofile.snapshots.min_free_space.value\fR" 6 +.RS +Type: int Allowed Values: 1-99999 +.br +Keep at least value + unit free space. +.PP +Default: 1 +.RE + +.IP "\fIprofile.snapshots.mode\fR" 6 +.RS +Type: str Allowed Values: local|local_encfs|ssh|ssh_encfs +.br + Use mode (or backend) for this snapshot. Look at 'man backintime' section 'Modes'. +.PP +Default: local +.RE + +.IP "\fIprofile.snapshots..password.save\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Save password to system keyring (gnome-keyring or kwallet). must be the same as \fIprofile.snapshots.mode\fR +.PP +Default: false +.RE + +.IP "\fIprofile.snapshots..password.use_cache\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Cache password in RAM so it can be read by cronjobs. Security issue: root might be able to read that password, too. must be the same as \fIprofile.snapshots.mode\fR +.PP +Default: true if home is not encrypted +.RE + +.IP "\fIprofile.snapshots.no_on_battery\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Don't take snapshots if the Computer runs on battery. +.PP +Default: false +.RE + +.IP "\fIprofile.snapshots.notify.enabled\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Display notifications (errors, warnings) through libnotify. +.PP +Default: true +.RE + +.IP "\fIprofile.snapshots.one_file_system\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Use rsync's "--one-file-system" to avoid crossing filesystem boundaries when recursing. +.PP +Default: false +.RE + +.IP "\fIprofile.snapshots.path\fR" 6 +.RS +Type: str Allowed Values: absolute path +.br +Where to save snapshots in mode 'local'. This path must contain a folderstructure like 'backintime///' +.PP +Default: '' +.RE + +.IP "\fIprofile.snapshots.path.host\fR" 6 +.RS +Type: str Allowed Values: text +.br +Set Host for snapshot path +.PP +Default: local hostname +.RE + +.IP "\fIprofile.snapshots.path.profile\fR" 6 +.RS +Type: str Allowed Values: 1-99999 +.br +Set Profile-ID for snapshot path +.PP +Default: current Profile-ID +.RE + +.IP "\fIprofile.snapshots.path.user\fR" 6 +.RS +Type: str Allowed Values: text +.br +Set User for snapshot path +.PP +Default: local username +.RE + +.IP "\fIprofile.snapshots.path.uuid\fR" 6 +.RS +Type: str Allowed Values: text +.br +Devices uuid used to automatically set up udev rule if the drive is not connected. +.PP +Default: '' +.RE + +.IP "\fIprofile.snapshots.preserve_acl\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Preserve ACL. The source and destination systems must have compatible ACL entries for this option to work properly. +.PP +Default: false +.RE + +.IP "\fIprofile.snapshots.preserve_xattr\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Preserve extended attributes (xattr). +.PP +Default: false +.RE + +.IP "\fIprofile.snapshots.remove_old_snapshots.enabled\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Remove all snapshots older than value + unit +.PP +Default: true +.RE + +.IP "\fIprofile.snapshots.remove_old_snapshots.unit\fR" 6 +.RS +Type: int Allowed Values: 20|30|80 +.br +20 = days +.br +30 = weeks +.br +80 = years +.PP +Default: 80 +.RE + +.IP "\fIprofile.snapshots.remove_old_snapshots.value\fR" 6 +.RS +Type: int Allowed Values: 0-99999 +.br +Snapshots older than this times units will be removed +.PP +Default: 10 +.RE + +.IP "\fIprofile.snapshots.rsync_options.enabled\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Past additional options to rsync +.PP +Default: false +.RE + +.IP "\fIprofile.snapshots.rsync_options.value\fR" 6 +.RS +Type: str Allowed Values: text +.br +rsync options. Options must be quoted e.g. \-\-exclude-from="/path/to/my exclude file" +.PP +Default: '' +.RE + +.IP "\fIprofile.snapshots.smart_remove\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Run smart_remove to clean up old snapshots after a new snapshot was created. +.PP +Default: false +.RE + +.IP "\fIprofile.snapshots.smart_remove.keep_all\fR" 6 +.RS +Type: int Allowed Values: 0-99999 +.br +Keep all snapshots for X days. +.PP +Default: 2 +.RE + +.IP "\fIprofile.snapshots.smart_remove.keep_one_per_day\fR" 6 +.RS +Type: int Allowed Values: 0-99999 +.br +Keep one snapshot per day for X days. +.PP +Default: 7 +.RE + +.IP "\fIprofile.snapshots.smart_remove.keep_one_per_month\fR" 6 +.RS +Type: int Allowed Values: 0-99999 +.br +Keep one snapshot per month for X month. +.PP +Default: 24 +.RE + +.IP "\fIprofile.snapshots.smart_remove.keep_one_per_week\fR" 6 +.RS +Type: int Allowed Values: 0-99999 +.br +Keep one snapshot per week for X weeks. +.PP +Default: 4 +.RE + +.IP "\fIprofile.snapshots.smart_remove.run_remote_in_background\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +If using mode SSH or SSH-encrypted, run smart_remove in background on remote machine +.PP +Default: false +.RE + +.IP "\fIprofile.snapshots.ssh.check_commands\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Check if all commands (used during takeSnapshot) work like expected on the remote host. +.PP +Default: true +.RE + +.IP "\fIprofile.snapshots.ssh.check_ping\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Check if the remote host is available before trying to mount. +.PP +Default: true +.RE + +.IP "\fIprofile.snapshots.ssh.cipher\fR" 6 +.RS +Type: str Allowed Values: default | aes192-cbc | aes256-cbc | aes128-ctr | aes192-ctr | aes256-ctr | arcfour | arcfour256 | arcfour128 | aes128-cbc | 3des-cbc | blowfish-cbc | cast128-cbc +.br +Cipher that is used for encrypting the SSH tunnel. Depending on the environment (network bandwidth, cpu and hdd performance) a different cipher might be faster. +.PP +Default: default +.RE + +.IP "\fIprofile.snapshots.ssh.host\fR" 6 +.RS +Type: str Allowed Values: IP or domain address +.br +Remote host used for mode 'ssh' and 'ssh_encfs'. +.PP +Default: '' +.RE + +.IP "\fIprofile.snapshots.ssh.ionice\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Run rsync and other commands on remote host with 'ionice \-c2 \-n7' +.PP +Default: false +.RE + +.IP "\fIprofile.snapshots.ssh.max_arg_length\fR" 6 +.RS +Type: int Allowed Values: 0, >700 +.br +Maximum command length of commands run on remote host. This can be tested for all ssh profiles in the configuration with 'python3 /usr/share/backintime/common/sshMaxArg.py LENGTH'. +.br +0 = unlimited +.PP +Default: 0 +.RE + +.IP "\fIprofile.snapshots.ssh.nice\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Run rsync and other commands on remote host with 'nice \-n19' +.PP +Default: false +.RE + +.IP "\fIprofile.snapshots.ssh.nocache\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Run rsync on remote host with 'nocache'. This will prevent files from being cached in memory. +.PP +Default: false +.RE + +.IP "\fIprofile.snapshots.ssh.path\fR" 6 +.RS +Type: str Allowed Values: absolute or relative path +.br +Snapshot path on remote host. If the path is relative (no leading '/') it will start from remote Users homedir. An empty path will be replaced with './'. +.PP +Default: '' +.RE + +.IP "\fIprofile.snapshots.ssh.port\fR" 6 +.RS +Type: int Allowed Values: 0-65535 +.br +SSH Port on remote host. +.PP +Default: 22 +.RE + +.IP "\fIprofile.snapshots.ssh.prefix.enabled\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Add prefix to every command which run through SSH on remote host. +.PP +Default: false +.RE + +.IP "\fIprofile.snapshots.ssh.prefix.value\fR" 6 +.RS +Type: str Allowed Values: text +.br +Prefix to run before every command on remote host. Variables need to be escaped with \\$FOO. This doesn't touch rsync. So to add a prefix for rsync use \fIprofile.snapshots.rsync_options.value\fR with --rsync-path="FOO=bar:\\$FOO /usr/bin/rsync" +.PP +Default: 'PATH=/opt/bin:/opt/sbin:\\$PATH' +.RE + +.IP "\fIprofile.snapshots.ssh.private_key_file\fR" 6 +.RS +Type: str Allowed Values: absolute path to private key file +.br +Private key file used for password-less authentication on remote host. +.PP +Default: ~/.ssh/id_dsa +.RE + +.IP "\fIprofile.snapshots.ssh.proxy_host\fR" 6 +.RS +Type: str Allowed Values: text +.br +Proxy host used to connect to remote host. +.PP +Default: IP or domain address +.RE + +.IP "\fIprofile.snapshots.ssh.proxy_host_port\fR" 6 +.RS +Type: int Allowed Values: 0-65535 +.br +SSH Port on remote proxy host. +.PP +Default: 22 +.RE + +.IP "\fIprofile.snapshots.ssh.proxy_user\fR" 6 +.RS +Type: str Allowed Values: text +.br +Remote SSH Proxy user +.PP +Default: getpass.getuser( +.RE + +.IP "\fIprofile.snapshots.ssh.user\fR" 6 +.RS +Type: str Allowed Values: text +.br +Remote SSH user +.PP +Default: local users name +.RE + +.IP "\fIprofile.snapshots.take_snapshot_regardless_of_changes\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Create a new snapshot regardless if there were changes or not. +.PP +Default: false +.RE + +.IP "\fIprofile.snapshots.use_checksum\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Use checksum to detect changes rather than size + time. +.PP +Default: false +.RE + +.IP "\fIprofile.snapshots.user_backup.ionice\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Run BackInTime with 'ionice \-c2 \-n7' when taking a manual snapshot. This will give BackInTime the lowest IO bandwidth priority to not interrupt any other working process. +.PP +Default: false +.RE + +.IP "\fIprofile.user_callback.no_logging\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Do not catch std{out|err} from user-callback script. The script will only write to current TTY. Default is to catch std{out|err} and write it to syslog and TTY again. +.PP +Default: false +.RE + +.IP "\fIprofiles\fR" 6 +.RS +Type: str Allowed Values: int separated by colon (e.g. 1:3:4) +.br +All active Profiles ( in profile.snapshots...). +.PP +Default: 1 +.RE + +.IP "\fIprofiles.version\fR" 6 +.RS +Type: int Allowed Values: 1 +.br +Internal version of profiles config. +.PP +Default: 1 +.RE +.SH SEE ALSO +backintime, backintime-qt. +.PP +Back In Time also has a website: https://github.com/bit-team/backintime +.SH AUTHOR +This manual page was written by BIT Team(). diff --git a/common/singleton.py b/common/singleton.py index a03e859ec..f5f4a4008 100644 --- a/common/singleton.py +++ b/common/singleton.py @@ -6,8 +6,7 @@ # This file is released under Creative Commons Zero 1.0 (CC0-1.0) and part of # the program "Back In Time". The program as a whole is released under GNU # General Public License v2 or any later version (GPL-2.0-or-later). -# See file/folder LICENSE or -# go to +# See LICENSES directory or go to # and . # # Credits to Mr. Mars Landis describing that solution and comparing it to @@ -17,7 +16,6 @@ # question as his inspiration. # # Original code adapted by Christian Buhtz. - """Flexible and pythonic singleton implementation. Support inheritance and multiple classes. Multilevel inheritance is diff --git a/common/test/test_konfig.py b/common/test/test_konfig.py new file mode 100644 index 000000000..c83ad2fe6 --- /dev/null +++ b/common/test/test_konfig.py @@ -0,0 +1,191 @@ +# SPDX-FileCopyrightText: © 2024 Christian BUHTZ +# +# SPDX-License-Identifier: GPL-2.0-or-later +# +# This file is part of the program "Back In Time" which is released under GNU +# General Public License v2 (GPLv2). +# See file LICENSE or go to . +import unittest +import configparser +import pyfakefs.fake_filesystem_unittest as pyfakefs_ut +from pathlib import Path +from io import StringIO +from konfig import Konfig + + +class General(unittest.TestCase): + """Konfig class""" + + def setUp(self): + Konfig._instances = {} + + def test_empty(self): + """Empty config file""" + sut = Konfig(StringIO('')) + + self.assertEqual( + dict(sut._conf.items()), + {'profile1.name': 'Main profile'} + ) + + def test_default_values(self): + """Default values and their types of fields if not present.""" + sut = Konfig(StringIO('')) + + self.assertEqual(sut.global_flock, False) + self.assertIsInstance(sut.global_flock, bool) + self.assertEqual(sut.language, '') + self.assertIsInstance(sut.language, str) + self.assertEqual(sut.hash_collision, 0) + self.assertIsInstance(sut.hash_collision, int) + + def test_no_interpolation(self): + """Interpolation should be turned off""" + try: + Konfig(StringIO('qt.diff.params=%6 %1 %2')) + except configparser.InterpolationSyntaxError as exc: + self.fail(f'InterpolationSyntaxError was raised. {exc}') + + +class Read(unittest.TestCase): + """Read a config file/object""" + + def setUp(self): + Konfig._instances = {} + + def test_from_memory_via_ctor(self): + """Config in memory""" + buffer = StringIO('global.language=xz') + sut = Konfig(buffer) + + self.assertEqual(sut.language, 'xz') + + def test_from_memory_via_load(self): + """Config in memory""" + sut = Konfig() + self.assertEqual(sut.language, '') + + buffer = StringIO('global.language=ab') + sut.load(buffer) + self.assertEqual(sut.language, 'ab') + + @pyfakefs_ut.patchfs + def test_from_file_via_ctor(self, fake_fs): + """Config in from file""" + fp = Path.cwd() / 'file' + with fp.open('w', encoding='utf-8') as handle: + handle.write('global.language=rt\n') + + with fp.open('r', encoding='utf-8') as handle: + sut = Konfig(handle) + self.assertEqual(sut.language, 'rt') + + @pyfakefs_ut.patchfs + def test_from_file_via_load(self, fake_fs): + """Config in from file""" + sut = Konfig() + self.assertEqual(sut.language, '') + + fp = Path.cwd() / 'filezwei' + with fp.open('w', encoding='utf-8') as handle: + handle.write('global.language=wq\n') + + with fp.open('r', encoding='utf-8') as handle: + sut.load(handle) + self.assertEqual(sut.language, 'wq') + + +class Profiles(unittest.TestCase): + """Konfig.Profile class""" + + def setUp(self): + Konfig._instances = {} + + def test_empty(self): + """Profile child objects""" + konf = Konfig(StringIO('')) + sut = konf.profile(1) + self.assertEqual(sut['name'], 'Main profile') + + def test_default_values(self): + """Default values and their types of fields if not present.""" + sut = Konfig(StringIO('')).profile(0) + + self.assertEqual(sut.ssh_check_commands, True) + self.assertIsInstance(sut.ssh_check_commands, bool) + self.assertEqual(sut.ssh_cipher, 'default') + self.assertIsInstance(sut.ssh_cipher, str) + self.assertEqual(sut.ssh_port, 22) + self.assertIsInstance(sut.ssh_port, int) + + +class IncExc(unittest.TestCase): + """About include and exclude fields""" + + def setUp(self): + Konfig._instances = {} + + def test_exclude_write(self): + """Write exclude fields""" + config = Konfig() + sut = config.profile(1) + + self.assertEqual(sut.exclude, []) + + sut.exclude = ['Worf', 'Garak'] + + self.assertEqual(sut.exclude, ['Worf', 'Garak']) + + def test_include_write(self): + """Write include fields""" + config = Konfig() + sut = config.profile(1) + + self.assertEqual(sut.include, []) + + sut.include = [ + ('/Cardassia/Prime', 0), + ('/Ferengi/Nar', 1), + ] + + self.assertEqual( + sut.include, + [ + ('/Cardassia/Prime', 0), + ('/Ferengi/Nar', 1), + ] + ) + + def test_include_read(self): + """Read include fields""" + config = Konfig(StringIO('\n'.join([ + 'profile1.snapshots.include.1.value=/foo/bar/folder', + 'profile1.snapshots.include.1.type=0', + 'profile1.snapshots.include.2.value=/foo/bar/file', + 'profile1.snapshots.include.2.type=1', + ]))) + sut = config.profile(1) + + self.assertEqual( + sut.include, + [ + ('/foo/bar/folder', 0), + ('/foo/bar/file', 1) + ] + ) + + def test_exclude_read(self): + """Read exclude fields""" + config = Konfig(StringIO('\n'.join([ + 'profile1.snapshots.exclude.2.value=/bar/foo/file', + 'profile1.snapshots.exclude.1.value=/bar/foo/folder', + ]))) + sut = config.profile(1) + + self.assertEqual( + sut.exclude, + [ + '/bar/foo/file', + '/bar/foo/folder', + ] + ) diff --git a/common/test/test_lint.py b/common/test/test_lint.py index 7204774bf..2a5a25ef2 100644 --- a/common/test/test_lint.py +++ b/common/test/test_lint.py @@ -43,6 +43,7 @@ # Files in this lists will get the full battery of linters and rule sets. full_test_files = [_base_dir / fp for fp in ( 'bitbase.py', + 'konfig.py', 'languages.py', 'schedule.py', 'singleton.py', @@ -236,6 +237,7 @@ def test010_ruff_default_ruleset(self): @unittest.skipUnless(FLAKE8_AVAILABLE, BASE_REASON.format('flake8')) def test020_flake8_default_ruleset(self): """Flake8 in default mode.""" + cmd = [ 'flake8', f'--max-line-length={PEP8_MAX_LINE_LENGTH}', diff --git a/common/test/test_plugin_usercallback.py b/common/test/test_plugin_usercallback.py index ec4e9710b..056d0fd44 100644 --- a/common/test/test_plugin_usercallback.py +++ b/common/test/test_plugin_usercallback.py @@ -26,7 +26,7 @@ from usercallbackplugin import UserCallbackPlugin -class UserCallback(unittest.TestCase): +class Reasons(unittest.TestCase): """Simple test related to to UserCallbackPlugin class. Dev note (buhtz, 2024-02-08): Test value is low because they depend on @@ -40,6 +40,7 @@ class UserCallback(unittest.TestCase): - Unit tests about logger output. But migrate "logger" to Python's inbuild "logging" module first. """ + def _generic_called_with(self, the_step, reason, *args): sut = UserCallbackPlugin() sut.config = Config() @@ -51,16 +52,16 @@ def _generic_called_with(self, the_step, reason, *args): func_callback.assert_called_once() func_callback.assert_called_with(reason, *args) - def test_reason_processBegin(self): + def test_processBegin(self): self._generic_called_with(UserCallbackPlugin.processBegin, '1') - def test_reason_processEnd(self): + def test_processEnd(self): self._generic_called_with(UserCallbackPlugin.processEnd, '2') - def test_reason_processnewSnapshot(self): + def test_processnewSnapshot(self): self._generic_called_with(UserCallbackPlugin.newSnapshot, '3', 'id1', 'path') - def test_reason_error(self): + def test_error(self): sut = UserCallbackPlugin() sut.config = Config() sut.script = '' @@ -79,13 +80,13 @@ def test_reason_error(self): func_callback.assert_called_once() func_callback.assert_called_with('4', 'code2') - def test_reason_appStart(self): + def test_appStart(self): self._generic_called_with(UserCallbackPlugin.appStart, '5') - def test_reason_appExit(self): + def test_appExit(self): self._generic_called_with(UserCallbackPlugin.appExit, '6') - def test_reason_mount(self): + def test_mount(self): sut = UserCallbackPlugin() sut.config = Config() sut.script = '' @@ -104,7 +105,7 @@ def test_reason_mount(self): func_callback.assert_called_once() func_callback.assert_called_with('7', profileID='123') - def test_reason_unmount(self): + def test_unmount(self): sut = UserCallbackPlugin() sut.config = Config() sut.script = '' diff --git a/create-manpage-backintime-config.py b/create-manpage-backintime-config.py index 17b8e2a06..4bf323446 100755 --- a/create-manpage-backintime-config.py +++ b/create-manpage-backintime-config.py @@ -37,72 +37,58 @@ force_default and force_var. If there is no forced value it will chose the value based on the instance with `select_values` """ - -import re import os import sys +import re +import inspect +import subprocess +from pathlib import Path from time import strftime, gmtime +from typing import Any +# Workaround (see #1575) +sys.path.insert(0, str(Path.cwd() / 'common')) +import konfig +import version + +MAN = Path.cwd() / 'common' / 'man' / 'C' / 'backintime-config.1' + +# Extract multiline string between { and the latest } +REX_DICT_EXTRACT = re.compile(r'\{([\s\S]*)\}') +# Extract attribute name +REX_ATTR_NAME = re.compile(r"self(?:\._conf)?\[['\"](.*)['\"]\]") + +# |--------------------------| +# | GNU Trof (groff) helpers | +# |--------------------------| + +def groff_section(section: str) -> str: + """Section header""" + return f'.SH {section}\n' -PATH = os.path.join(os.getcwd(), 'common') - -CONFIG = os.path.join(PATH, 'config.py') -MAN = os.path.join(PATH, 'man/C/backintime-config.1') - -with open(os.path.join(PATH, '../VERSION'), 'r') as f: - VERSION = f.read().strip('\n') - -SORT = True # True = sort by alphabet; False = sort by line numbering - -c_list = re.compile(r'.*?self\.(?!set)((?:profile)?)(List)Value ?\( ?[\'"](.*?)[\'"], ?((?:\(.*\)|[^,]*)), ?[\'"]?([^\'",\)]*)[\'"]?') -c = re.compile(r'.*?self\.(?!set)((?:profile)?)(.*?)Value ?\( ?[\'"](.*?)[\'"] ?(%?[^,]*?), ?[\'"]?([^\'",\)]*)[\'"]?') - -HEADER = r'''.TH backintime-config 1 "%s" "version %s" "USER COMMANDS" -.SH NAME -config \- BackInTime configuration files. -.SH SYNOPSIS -~/.config/backintime/config -.br -/etc/backintime/config -.SH DESCRIPTION -Back In Time was developed as pure GUI program and so most functions are only -usable with backintime-qt. But it is possible to use -Back In Time e.g. on a headless server. You have to create the configuration file -(~/.config/backintime/config) manually. Look inside /usr/share/doc/backintime\-common/examples/ for examples. -.PP -The configuration file has the following format: -.br -keyword=arguments -.PP -Arguments don't need to be quoted. All characters are allowed except '='. -.PP -Run 'backintime check-config' to verify the configfile, create the snapshot folder and crontab entries. -.SH POSSIBLE KEYWORDS -''' % (strftime('%b %Y', gmtime()), VERSION) - -FOOTER = r'''.SH SEE ALSO -backintime, backintime-qt, backintime-askpass. -.PP -Back In Time also has a website: https://github.com/bit-team/backintime -.SH AUTHOR -This manual page was written by BIT Team(). -''' - -INSTANCE = 'instance' -NAME = 'name' -VALUES = 'values' -DEFAULT = 'default' -COMMENT = 'comment' -REFERENCE = 'reference' -LINE = 'line' def groff_indented_paragraph(label: str, indent: int=6) -> str: """.IP - Indented Paragraph""" return f'.IP "{label}" {indent}' + def groff_italic(text: str) -> str: """\\fi - Italic""" return f'\\fI{text}\\fR' + +def groff_bold(text: str) -> str: + """Bold""" + return f'\\fB{text}\\fR' + + +def groff_bold_roman(text: str) -> str: + """The first part of the text is marked bold the rest is + roman/normal. + + Used to reference other man pages.""" + return f'.BR {text}\n' + + def groff_indented_block(text: str) -> str: """ .RS - Start indented block @@ -110,30 +96,87 @@ def groff_indented_block(text: str) -> str: """ return f'\n.RS\n{text}\n.RE\n' + def groff_linebreak() -> str: """.br - Line break""" return '.br\n' + def groff_paragraph_break() -> str: """.PP - Paragraph break""" return '.PP\n' +# |--------------------| +# | Content generation | +# |--------------------| + + +def header(): + stamp = strftime('%b %Y', gmtime()) + ver = version.__version__ + + content = ''.join([ + f'.TH backintime-config 1 "{stamp}" ' + f'"version {ver}" "USER COMMANDS"\n', + + groff_section('NAME'), + 'config \\- Back In Time configuration file.\n', + + groff_section('SYNOPSIS'), + '~/.config/backintime/config\n', + groff_linebreak(), + '/etc/backintime/config\n', -def output(instance='', name='', values='', default='', - comment='', reference='', line=0): + groff_section('DESCRIPTION'), + 'Back In Time was developed as pure GUI program and so most ' + 'functions are only usable with ', + groff_bold('backintime-qt'), + '. But it is possible to use Back In Time e.g. on a ' + 'headless server. You have to create the configuration file ' + '(~/.config/backintime/config) manually. Look inside ' + '/usr/share/doc/backintime\\-common/examples/ for examples.\n', + + groff_paragraph_break(), + 'The configuration file has the following format:\n', + groff_linebreak(), + 'keyword=arguments\n', + + groff_paragraph_break(), + "Arguments don't need to be quoted. All characters are " + "allowed except '='.\n", + + groff_paragraph_break(), + "Run 'backintime check-config' to verify the configfile, " + "create the snapshot folder and crontab entries.\n", + + groff_section('POSSIBLE KEYWORDS'), + ]) + + return content + + +def entry_to_groff(name: str, + doc: str, + values: Any, + default: Any, + its_type: type) -> None: """Generate GNU Troff (groff) markup code for the given config entry.""" - if not default: - default = "''" - ret = f'Type: {instance.lower():<10}Allowed Values: {values}\n' + if its_type is not None: + if isinstance(its_type, str): + type_name = its_type + else: + type_name = its_type.__name__ + else: + type_name = '' + + ret = f'Type: {type_name:<10}Allowed Values: {values}\n' ret += groff_linebreak() - ret += f'{comment}\n' + ret += f'{doc}\n' ret += groff_paragraph_break() - if SORT: + if default is not None: ret += f'Default: {default}' - else: - ret += f'Default: {default:<18} {reference} line: {line}' ret = groff_indented_block(ret) ret = groff_indented_paragraph(groff_italic(name)) + ret @@ -141,261 +184,309 @@ def output(instance='', name='', values='', default='', return ret -def select(a, b): - if a: - return a +def footer() -> str: + content = groff_section('SEE ALSO') + content += groff_bold_roman('backintime (1),') + content += groff_bold_roman('backintime-qt (1)') + content += groff_paragraph_break() + content += 'Back In Time also has a website: ' \ + 'https://github.com/bit-team/backintime\n' - return b + content += groff_section('AUTHOR') + content += 'This manual page was written by the ' \ + 'Back In Time Team ().' + return content -def select_values(instance, values): - if values: - return values +# |------| +# | Misc | +# |------| - return { - 'bool': 'true|false', - 'str': 'text', - 'int': '0-99999' - }[instance.lower()] +def _get_public_properties(cls: type) -> tuple: + """Extract the public properties from our target config class.""" + def _is_public_property(val): + return ( + not val.startswith('_') + and isinstance(getattr(cls, val), property) + ) -def process_line(d, key, profile, instance, name, var, default, commentline, - values, force_var, force_default, replace_default, counter): - """Parsing the config.py Python code""" - # Ignore commentlines with #?! and 'config.version' - comment = None + return tuple(filter(_is_public_property, cls.__dict__.keys())) - if not commentline.startswith('!') and key not in d: - d[key] = {} - commentline = commentline.split(';') - try: - comment = commentline[0] - values = commentline[1] - force_default = commentline[2] - force_var = commentline[3] +def lint_manpage(path: Path) -> bool: + """Lint the manpage the same way as the Debian Lintian does.""" - except IndexError: - pass + print('Linting man page…') - if default.startswith('self.') and default[5:] in replace_default: - default = replace_default[default[5:]] + cmd = [ + 'man', + '--warnings', + '-E', + 'UTF-8', + '-l', + '-Tutf8', + '-Z', + str(path) + ] - if isinstance(force_default, str) \ - and force_default.startswith('self.') \ - and force_default[5:] in replace_default: - force_default = replace_default[force_default[5:]] + env = dict( + **os.environ, + LC_ALL='C.UTF-8', + # MANROFFSEQ="''", + MANWIDTH='80', + ) - if instance.lower() == 'bool': - default = default.lower() + try: + with open('/dev/null', 'w') as devnull: + result = subprocess.run( + cmd, + env=env, + check=True, + text=True, + stdout=devnull, + stderr=subprocess.PIPE + ) - d[key][INSTANCE] = instance - d[key][NAME] = re.sub( - r'%[\S]', '<%s>' % select(force_var, var).upper(), name - ) - d[key][VALUES] = select_values(instance, values) - d[key][DEFAULT] = select(force_default, default) - d[key][COMMENT] = re.sub(r'\\n', '\n.br\n', comment) - d[key][REFERENCE] = 'config.py' - d[key][LINE] = counter + except subprocess.CalledProcessError as exc: + raise RuntimeError(f'Unexpected error: {exc.stderr=}') from exc + # Report warnings + if result.stderr: + print(result.stderr) + return False -def main(): - d = { - 'profiles.version': { - INSTANCE: 'int', - NAME: 'profiles.version', - VALUES: '1', - DEFAULT: '1', - COMMENT: 'Internal version of profiles config.', - REFERENCE: 'configfile.py', - LINE: 419 - }, - 'profiles': { - INSTANCE: 'str', - NAME: 'profiles', - VALUES: 'int separated by colon (e.g. 1:3:4)', - DEFAULT: '1', - COMMENT: 'All active Profiles ( in profile.snapshots...).', - REFERENCE: 'configfile.py', - LINE: 472 - }, - 'profile.name': { - INSTANCE: 'str', - NAME: 'profile.name', - VALUES: 'text', - DEFAULT: 'Main profile', - COMMENT: 'Name of this profile.', - REFERENCE: 'configfile.py', - LINE: 704 + print('No problems reported.') + + return True + + +def inspect_properties(cls: type, name_prefix: str = '') -> dict[str, dict]: + """Collect details about propiertes of the class `cls`. + + All public properties containing a doc string are considered. + Some values can be specified with a dictionary contained in the doc string + but don't have to, except the 'values' field containing the allowed values. + The docstring is used as description ('doc'). The type annoation of the + return value is used as 'type'. The name of the config field is extracted + from the code body of the property method. + + Example of a result :: + + { + 'global.hash_collision': + { + 'values': '0-99999', + 'default': 0, + 'doc': 'Internal value ...', + 'type': 'int' + }, } - } - # Default variables and there values collected from config.py - replace_default = {} - - # Variables named "CONFIG_VERSION" or with names starting with "DEFAULT" - regex_default = re.compile(r'(^DEFAULT[\w]*|CONFIG_VERSION)[\s]*= (.*)') - - with open(CONFIG, 'r') as f: - print(f'Read and parse "{CONFIG}".') - commentline = '' - values = force_var = force_default = instance \ - = name = var = default = None - - for counter, line in enumerate(f, 1): - line = line.lstrip() - - # parse for DEFAULT variable - m_default = regex_default.match(line) - - # DEFAULT variable - if m_default: - replace_default[m_default.group(1)] \ - = m_default.group(2) - continue - - # Comment intended to use for the manpage - if line.startswith('#?'): - if commentline \ - and ';' not in commentline \ - and not commentline.endswith('\\n'): - commentline += ' ' - - commentline += line.lstrip('#?').rstrip('\n') - - continue - - # Simple comments are ignored - if line.startswith('#'): - commentline = '' - continue - - # e.g. "return self.profileListValue('snapshots.include', ('str:value', 'int:type'), [], profile_id)" - # becomes - # ('profile', 'List', 'snapshots.include', "('str:value', 'int:type')", '[]') - m = c_list.match(line) - - if not m: - # e.g. "return self.profileBoolValue('snapshots.use_checksum', False, profile_id)" - # becomes - # ('profile', 'Bool', 'snapshots.use_checksum', '', 'False') - m = c.match(line) - - # Ignore undocumented (without "#?" comments) variables. - if m and not commentline: - continue - - if m: - profile, instance, name, var, default = m.groups() - - if profile == 'profile': - name = 'profile.' + name - - var = var.lstrip('% ') - - if instance.lower() == 'list': - - type_key = [x.strip('"\'') for x in re.findall(r'["\'].*?["\']', var)] - - commentline_split = commentline.split('::') - - for i, tk in enumerate(type_key): - t, k = tk.split(':', maxsplit=1) - - process_line( - d, - key, - profile, - 'int', - '%s.size' % name, - var, - r'\-1', - 'Quantity of %s. entries.' % name, - values, - force_var, - force_default, - replace_default, - counter) - - key = '%s.%s' % (name, k) - key = key.lower() - - process_line( - d, - key, - profile, - t, - '%s..%s' % (name, k), - var, - '', - commentline_split[i], - values, - force_var, - force_default, - replace_default, - counter - ) - - else: - key = re.sub(r'%[\S]', var, name).lower() - - process_line( - d, - key, - profile, - instance, - name, - var, - default, - commentline, - values, - force_var, - force_default, - replace_default, - counter - ) + Results in a man page entry like this :: + + POSSIBLE KEYWORDS - values = force_var = force_default = instance \ - = name = var = default = None + global.hash_collision + Type: int Allowed Values: 0-99999 + Internal value ... - commentline = '' + Default: 0 + Returns: + A dictionary indexed by the config option field names. """ - Example for content of 'd': + # The folloing fields/properties will produce warnings. But this is + # expected on happens on purpose. The list is used to "ease" the warning + # message. + expect_to_be_ignored = ( + 'Konfig.profile_names', + 'Konfig.profile_ids', + 'Konfig.manual_starts_countdown', + 'Profile.include', + 'Profile.exclude', + 'Profile.keep_named_snapshots', + ) + + entries = {} + + # Each public property in the class + for prop in _get_public_properties(cls): + attr = getattr(cls, prop) + + # Ignore properties without docstring + if not attr.__doc__: + full_prop = f'{cls.__name__}.{prop}' + ok = '(No problem) ' if full_prop in expect_to_be_ignored else '' + print(f'{ok}Ignoring "{full_prop}" because of ' + 'missing docstring.') + continue + + # DEBUG + # print(f'{cls.__name__}.{prop}') + + # Extract config field name from code (self._conf['config.field']) + try: + name = REX_ATTR_NAME.findall(inspect.getsource(attr.fget))[0] + name = f'{name_prefix}{name}' + except IndexError as exc: + full_prop = f'{cls.__name__}.{prop}' + ok = '(No problem) ' if full_prop in expect_to_be_ignored else '' + print(f'{ok}Ignoring "{full_prop}" because it is not ' + 'possible to find name of config field in its body.') + continue + + # Full doc string + doc = attr.__doc__ + + # extract the dict from docstring + try: + the_dict = REX_DICT_EXTRACT.search(doc).groups()[0] + except AttributeError: + the_dict = '' + + the_dict = '{' + the_dict + '}' + + # remove the dict from docstring + doc = doc.replace(the_dict, '') + + # Make it a real dict + the_dict = eval(the_dict) + + # Clean up the docstring from empty lines and other blanks + doc = ' '.join(line.strip() + for line in + filter(lambda val: len(val.strip()), doc.split('\n'))) + + # store the result + the_dict['doc'] = doc + + # default value + if 'default' not in the_dict: + try: + the_dict['default'] = cls._DEFAULT_VALUES[name] + except KeyError: + pass + + # type (by return value annotation) + if 'type' not in the_dict: + return_type = inspect.signature(attr.fget).return_annotation + + if return_type != inspect.Signature.empty: + the_dict['type'] = return_type + + # type by default values + if 'type' not in the_dict and 'default' in the_dict: + the_dict['type'] = type(the_dict['default']).__name__ + + # values if bool + if 'values' not in the_dict: + if the_dict['type'] == 'bool': + the_dict['values'] = 'true|false' + elif the_dict['type'] == 'int': + the_dict['values'] = '0-99999' + elif the_dict['type'] == 'str': + the_dict['values'] = 'text' + + + entries[name] = the_dict + + # DEBUG + # print(f'entries[{name}]={entries[name]}') + + return entries + + +def main(): + """The classes `Konfig` and `Konfig.Profile` are inspected and relevant + information is extracted to create a man page of it. + + Only public properties with doc strings are used. The doc strings also + need to contain a dict with additional information like allowed values and + default values. The data type is determined form the default value. The + property name is determined from the property methods name. + + Example :: + { - "profiles": { - "instance": "str", - "name": "profiles", - "values": "int separated by colon (e.g. 1:3:4)", - "default": "1", - "comment": "All active Profiles ( in profile.snapshots...).", - "reference": "configfile.py", - "line": 472 - }, - "profile.name": { - "instance": "str", - "name": "profile.name", - "values": "text", - "default": "Main profile", - "comment": "Name of this profile.", - "reference": "configfile.py", - "line": 704 + 'values': (0, 99999), + 'default': 0, } """ - with open(MAN, 'w') as f: - print(f'Write GNU Troff (groff) markup to "{MAN}". {SORT=}') - f.write(HEADER) - if SORT: - # Sort by alphabet - s = lambda x: x - else: - # Sort by line numbering (in the source file) - s = lambda x: d[x][LINE] + # Inspect the classes and extract man page related data from them. + global_entries = inspect_properties( + cls=konfig.Konfig, + ) + profile_entries = inspect_properties( + cls=konfig.Profile, + name_prefix='profile.' + ) + + # WORKAROuND: + # Structure of include/exclude fields can not be easly handled via + # properties and doc-string inspection. The structure will get + # modified in the future. Until then we add their man page docu + # manual. + inc_exc = { + 'profile.snapshots.exclude..value': { + 'doc': 'Exclude this file or folder. must be a counter ' + 'starting with 1', + 'values': 'file, folder or pattern (relative or absolute)', + 'default': '', + 'type': 'str' + }, + # Don't worry. "exclude..type" does not exist. + 'profile.snapshots.include..value': { + 'doc': 'Include this file or folder. must be a counter ' + 'starting with 1', + 'values': 'absolute path', + 'default': '', + 'type': 'str' + }, + 'profile.snapshots.include..type': { + 'doc': + 'Specify if ' + groff_indented_block( + 'profile.snapshots.include..value') + + ' is a folder (0) or a file (1)', + 'values': '0|1', + 'default': 0, + 'type': 'int' + }, + } + + # Create the man page file + with MAN.open('w', encoding='utf-8') as handle: + print(f'Write GNU Troff (groff) markup to "{MAN}".') + + # HEADER + handle.write(header()) + + # PROPERTIES + for name, entry in {**global_entries, **profile_entries}.items(): + try: + handle.write( + entry_to_groff( + name=name, + doc=entry['doc'], + values=entry['values'], + default=entry.get('default', None), + its_type=entry.get('type', None), + ) + ) + except Exception as exc: + print(f'{name=} {entry=}') + raise exc + + handle.write('\n') + + # FOOTER + handle.write(footer()) + handle.write('\n') + + print('Finished creating man page.') - f.write('\n'.join(output(**d[key]) for key in sorted(d, key=s))) - f.write(FOOTER) + lint_manpage(MAN) if __name__ == '__main__': diff --git a/qt/test/test_lint.py b/qt/test/test_lint.py index 5e1ab5d11..abc955e49 100644 --- a/qt/test/test_lint.py +++ b/qt/test/test_lint.py @@ -230,6 +230,7 @@ def test010_ruff_default_ruleset(self): @unittest.skipUnless(FLAKE8_AVAILABLE, BASE_REASON.format('flake8')) def test020_flake8_default_ruleset(self): """Flake8 in default mode.""" + cmd = [ 'flake8', f'--max-line-length={PEP8_MAX_LINE_LENGTH}',