commit
944d4c282d
@ -0,0 +1 @@ |
||||
/.vscode |
@ -0,0 +1,395 @@ |
||||
""" |
||||
Nextcloud backup script |
||||
""" |
||||
from logging import getLogger, basicConfig, DEBUG, INFO, WARN, ERROR, CRITICAL |
||||
from subprocess import run, PIPE, CalledProcessError |
||||
from json import loads |
||||
from tempfile import NamedTemporaryFile, _TemporaryFileWrapper |
||||
from os import environ |
||||
from sys import stderr |
||||
from argparse import ArgumentParser, BooleanOptionalAction, ArgumentTypeError |
||||
from pathlib import Path |
||||
from typing import Optional, Any |
||||
|
||||
|
||||
log_levels = { |
||||
0: CRITICAL, |
||||
1: ERROR, |
||||
2: WARN, |
||||
3: INFO, |
||||
4: DEBUG, |
||||
} |
||||
|
||||
|
||||
class NextcloudBackup: |
||||
""" |
||||
Nextcloud backup class |
||||
""" |
||||
_db : Optional[dict] = None |
||||
_s3 : Optional[dict] = None |
||||
_psql_env : Optional[dict] = None |
||||
_s3_env : Optional[dict] = None |
||||
|
||||
def __init__(self, nextcloud: dict, backup_path: Path): |
||||
self.logger = getLogger(__name__) |
||||
self.nextcloud = nextcloud |
||||
self.backup_path = backup_path |
||||
|
||||
@staticmethod |
||||
def merge_env(env: dict) -> dict: |
||||
""" |
||||
Merge system environment with given environment |
||||
|
||||
:param env: New environment |
||||
:return: Merged environment |
||||
""" |
||||
tmp_env = environ.copy() |
||||
tmp_env.update(env) |
||||
return tmp_env |
||||
|
||||
def occ(self, occ_args: list, json: bool = True) -> Any: |
||||
""" |
||||
Run a Nextcloud OCC CLI command |
||||
|
||||
:param occ_args: OCC args |
||||
:param json: Add '--output=json" to args and parse the JSON, defaults to True |
||||
:return: Return the output of the command as string or as an object parsed if JSON enabled |
||||
""" |
||||
args = ["sudo", "-u", "www-data", "PHP_MEMORY_LIMIT=1G", self.nextcloud["php"], |
||||
f"{self.nextcloud['path']}/occ", "--no-interaction", "--no-ansi", "--no-warnings"] |
||||
|
||||
if json: |
||||
args += ["--output=json"] |
||||
|
||||
self.logger.debug(args + occ_args) |
||||
out = run(args + occ_args, check=True, stdout=PIPE, stderr=stderr).stdout.decode().strip() |
||||
self.logger.debug(out) |
||||
|
||||
if json: |
||||
return loads(out) |
||||
|
||||
return out |
||||
|
||||
def test_nextcloud(self) -> bool: |
||||
""" |
||||
Check if Nextcloud parameters are valid |
||||
|
||||
:return: True if the process can communicate with Nextcloud OCC, else False |
||||
""" |
||||
try: |
||||
self.occ(["check"], json=False) |
||||
return True |
||||
except CalledProcessError: |
||||
return False |
||||
|
||||
def nxc_maintenance(self, enabled: bool): |
||||
""" |
||||
Enable/disable Nextcloud maintenance mode |
||||
|
||||
:param enabled: Enable or disable maintenance |
||||
""" |
||||
self.logger.info("%s maintenance mode", 'Enabling' if enabled else 'Disabling') |
||||
self.occ(["maintenance:mode", f"--{'on' if enabled else 'off'}"], json=False) |
||||
|
||||
def rclone(self, args: Optional[list] = None, env: Optional[dict] = None, |
||||
json: bool = False) -> Any: |
||||
""" |
||||
Run Rclone command |
||||
|
||||
:param args: Rclone args, defaults to None |
||||
:param env: Rclone additional environment, defaults to None |
||||
:param json: Parse output as JSON, defaults to False |
||||
:return: Return the output of the command as string or as an object parsed if JSON enabled |
||||
""" |
||||
if args is None: |
||||
args = [] |
||||
if env is None: |
||||
env = {} |
||||
|
||||
env = self.merge_env(env) |
||||
|
||||
self.logger.debug({"args":["rclone"] + args}) |
||||
self.logger.debug(env) |
||||
out = run(["rclone"] + args, env=env, check=True, stdout=PIPE).stdout.decode().strip() |
||||
self.logger.debug(out) |
||||
|
||||
if json: |
||||
return loads(out) |
||||
|
||||
return out |
||||
|
||||
def rsync(self, args: Optional[list] = None, env: Optional[dict] = None): |
||||
""" |
||||
Run rsync command |
||||
|
||||
:param args: rsync args, defaults to None |
||||
:param env: rsync additional environment, defaults to None |
||||
:return: The competed process |
||||
""" |
||||
if args is None: |
||||
args = [] |
||||
if env is None: |
||||
env = {} |
||||
|
||||
env = self.merge_env(env) |
||||
|
||||
self.logger.debug({"args":["rsync"] + args, "env": env}) |
||||
return run(["rsync"] + args, env=env, check=True) |
||||
|
||||
def pg_dump(self, dump_file): |
||||
""" |
||||
Dump Postgres database to file |
||||
|
||||
:param dump_file: Target file to output the dump |
||||
""" |
||||
env = self.merge_env(self.psql_env) |
||||
|
||||
with open(dump_file, "w", encoding="utf-8") as dump_fd: |
||||
run(["pg_dump"], stdout=dump_fd, env=env, check=True) |
||||
|
||||
@property |
||||
def psql_env(self): |
||||
""" |
||||
Postgres CLI environment with cache |
||||
|
||||
:return: Postgres CLI environment |
||||
""" |
||||
if self._psql_env: |
||||
return self._psql_env |
||||
|
||||
env = { |
||||
"PGDATABASE": self.db["dbname"], |
||||
"PGHOST": self.db["dbhost"], |
||||
"PGPORT": self.db["dbport"], |
||||
"PGUSER": self.db["dbuser"], |
||||
"PGPASSWORD": self.db["dbpassword"] |
||||
} |
||||
self.logger.debug(env) |
||||
|
||||
self._psql_env = env |
||||
return env |
||||
|
||||
@property |
||||
def db(self) -> dict: |
||||
""" |
||||
Get database parameters from Nextcloud config with cache |
||||
|
||||
:return: Database parameters |
||||
""" |
||||
if self._db: |
||||
return self._db |
||||
|
||||
db = {} |
||||
for i in ["dbtype", "dbname", "dbhost", "dbport", "dbuser", "dbpassword"]: |
||||
db[i] = self.occ(["config:system:get", i]) |
||||
self.logger.debug(db) |
||||
|
||||
self._db = db |
||||
return db |
||||
|
||||
def test_db(self) -> bool: |
||||
""" |
||||
Check if database parameters are valid |
||||
|
||||
:return: True if the process can communicate with the database, else False |
||||
""" |
||||
try: |
||||
if self.db["dbtype"] == "pgsql": |
||||
run(["pg_isready"], env=self.psql_env, check=True) |
||||
else: |
||||
raise ArgumentTypeError("Unsupported database type") |
||||
return True |
||||
except CalledProcessError: |
||||
return False |
||||
|
||||
def dump_db(self) -> _TemporaryFileWrapper: |
||||
""" |
||||
Dump database to a file |
||||
|
||||
:raises ArgumentTypeError: If incompatible database |
||||
:return: Database file |
||||
""" |
||||
self.logger.info("Dumping databse") |
||||
dump_file = NamedTemporaryFile(delete=False) |
||||
self.logger.debug("Database dump file %s", dump_file.name) |
||||
|
||||
if self.db["dbtype"] == "pgsql": |
||||
self.pg_dump(dump_file.name) |
||||
else: |
||||
raise ArgumentTypeError("Unsupported database type") |
||||
|
||||
return dump_file |
||||
|
||||
def copy_db(self, dump_file: str): |
||||
""" |
||||
Copy database dump to the backup path |
||||
|
||||
:param dump_file: Database dump file |
||||
""" |
||||
self.logger.info("Copying database") |
||||
dest = self.backup_path/"db.sql" |
||||
|
||||
self.rsync([dump_file, str(dest)]) |
||||
|
||||
@property |
||||
def s3(self) -> dict: |
||||
""" |
||||
Get S3 parameters from Nextcloud config with cache |
||||
|
||||
:return: S3 parameters |
||||
""" |
||||
if self._s3: |
||||
return self._s3 |
||||
|
||||
s3 = {} |
||||
for i in ["bucket", "key", "secret", "hostname", "region", "port", "use_ssl"]: |
||||
s3[i] = self.occ(["config:system:get", "objectstore", "arguments", i]) |
||||
self.logger.debug(s3) |
||||
|
||||
self._s3 = s3 |
||||
return s3 |
||||
|
||||
@property |
||||
def s3_env(self) -> dict: |
||||
""" |
||||
S3 Rclone environment with cache |
||||
|
||||
:return: S3 Rclone environment |
||||
""" |
||||
if self._s3_env: |
||||
return self._s3_env |
||||
|
||||
env = { |
||||
"RCLONE_S3_PROVIDER": "minio", |
||||
"RCLONE_S3_ENV_AUTH": "true", |
||||
"RCLONE_S3_ACCESS_KEY_ID": self.s3["key"], |
||||
"RCLONE_S3_SECRET_ACCESS_KEY": self.s3["secret"], |
||||
"RCLONE_S3_REGION": self.s3["region"], |
||||
"RCLONE_S3_ENDPOINT": f"http{'s' if self.s3['use_ssl'] else ''}://"\ |
||||
f"{self.s3['hostname']}:{self.s3['port']}", |
||||
} |
||||
self.logger.debug(env) |
||||
|
||||
self._s3_env = env |
||||
return env |
||||
|
||||
def test_s3(self) -> bool: |
||||
""" |
||||
Check if S3 parameters are valid |
||||
|
||||
:return: True if Rclone can communicate with the S3, else False |
||||
|
||||
""" |
||||
try: |
||||
lsjson = self.rclone(["lsjson", "--max-depth=1", ":s3:"], self.s3_env, json=True) |
||||
|
||||
if self.s3["bucket"] not in map(lambda e: e.get("Path"), lsjson): |
||||
return False |
||||
|
||||
return True |
||||
except CalledProcessError: |
||||
return False |
||||
|
||||
def copy_s3(self, rclone_remote_control: bool = True): |
||||
""" |
||||
Copy S3 to the backup path with Rclone |
||||
|
||||
:param rclone_remote_control: Enable Rclone remote control, defaults to True |
||||
""" |
||||
self.logger.info("Copying S3") |
||||
env = self.s3_env |
||||
|
||||
dest = self.backup_path/"s3" |
||||
dest.mkdir(exist_ok=True) |
||||
|
||||
args = ["copy", f":s3:{self.s3['bucket']}/", str(dest)] |
||||
|
||||
if rclone_remote_control: |
||||
args = ["--rc"] + args |
||||
|
||||
self.rclone(args, env) |
||||
|
||||
def backup(self, maintenance : bool = True, rclone_remote_control: bool = True): |
||||
""" |
||||
Backup Nextcloud database and S3 to backup path |
||||
|
||||
:param maintenance: Enable maintenance during backup, defaults to True |
||||
:param rclone_remote_control: Enable Rclone remote control, defaults to True |
||||
""" |
||||
self.logger.info("Starting backup") |
||||
|
||||
if maintenance: |
||||
self.nxc_maintenance(True) |
||||
|
||||
dump_file = self.dump_db() |
||||
self.copy_db(dump_file.name) |
||||
|
||||
self.copy_s3(rclone_remote_control) |
||||
|
||||
if maintenance: |
||||
self.nxc_maintenance(False) |
||||
|
||||
def check(self): |
||||
""" |
||||
Check if last backup run successfully |
||||
TODO/WIP |
||||
""" |
||||
|
||||
|
||||
def main(): |
||||
""" |
||||
Main program function |
||||
""" |
||||
parser = ArgumentParser(prog="Nextcloud backup") |
||||
subparsers = parser.add_subparsers(dest="action", required=True) |
||||
|
||||
parser.add_argument("--verbose", "-v", dest="verbosity", action="count", default=0, |
||||
help="Verbosity (between 1-4 occurrences with more leading to more " |
||||
"verbose logging). CRITICAL=0, ERROR=1, WARN=2, INFO=3, " |
||||
"DEBUG=4") |
||||
|
||||
parser_backup = subparsers.add_parser("backup") |
||||
parser_backup.add_argument("--php", "-p", default="php", type=Path) |
||||
parser_backup.add_argument("--nextcloud", "-n", default="/opt/nextcloud", type=Path) |
||||
parser_backup.add_argument("--backup", "-b", default="/opt/backupnextcloud", type=Path) |
||||
parser_backup.add_argument("--maintenance", "-m", |
||||
action=BooleanOptionalAction, default=True) |
||||
parser_backup.add_argument("--rclone-remote-control", "-r", |
||||
action=BooleanOptionalAction, default=True) |
||||
|
||||
|
||||
parser_check = subparsers.add_parser("check") |
||||
parser_check.add_argument("--backup", "-b", default="/opt/backupnextcloud", type=Path) |
||||
|
||||
args = parser.parse_args() |
||||
basicConfig(level=log_levels[args.verbosity]) |
||||
|
||||
if not args.nextcloud.exists() or not args.nextcloud.is_dir(): |
||||
raise ArgumentTypeError("Invalid Nextcloud path") |
||||
|
||||
if not args.backup.exists() or not args.backup.is_dir(): |
||||
raise ArgumentTypeError("Invalid backup path") |
||||
|
||||
nextcloud = { |
||||
"php": args.php, |
||||
"path": args.nextcloud |
||||
} |
||||
|
||||
nb = NextcloudBackup(nextcloud, args.backup) |
||||
|
||||
if args.action == "backup": |
||||
if not nb.test_nextcloud(): |
||||
raise ArgumentTypeError("Nextcloud check faild") |
||||
|
||||
if not nb.test_db(): |
||||
raise ArgumentTypeError("Database check faild") |
||||
|
||||
if not nb.test_s3(): |
||||
raise ArgumentTypeError("S3 check faild") |
||||
|
||||
nb.backup(args.maintenance, args.rclone_remote_control) |
||||
elif args.action == "check": |
||||
nb.check() |
||||
|
||||
if __name__ == "__main__": |
||||
main() |
Loading…
Reference in new issue