Compare commits

...

6 Commits

Author SHA1 Message Date
Jannik cb90889036
Merge 936089ee99 into e6ca4aa9ca 2024-01-07 09:58:53 -05:00
einarf e6ca4aa9ca Add rtd config file 2024-01-05 04:13:20 +01:00
einarf 093dab93ca Make rcb dump env vars to properly escape them 2024-01-05 03:50:13 +01:00
Jannik 936089ee99 Update travis python version to 3.8 2020-05-28 17:50:18 +02:00
Jannik fbe77a10c0 Use docker exec api for database backups 2020-05-28 17:34:22 +02:00
Jannik fa887e1b5b exclude .venv dir from flake8 run 2020-05-28 14:26:56 +02:00
11 changed files with 150 additions and 72 deletions

24
.readthedocs.yaml Normal file
View File

@ -0,0 +1,24 @@
# Read the Docs configuration file for Sphinx projects
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
# Required
version: 2
# Set the OS, Python version and other tools you might need
build:
os: ubuntu-22.04
tools:
python: "3.10"
# Build documentation in the "docs/" directory with Sphinx
sphinx:
configuration: docs/conf.py
# Optionally build your docs in additional formats such as PDF and ePub
# formats:
# - pdf
# - epub
python:
install:
- requirements: docs/requirements.txt

View File

@ -4,7 +4,7 @@ sudo: false
matrix: matrix:
include: include:
python: 3.7 python: 3.8
dist: bionic dist: bionic
sudo: true sudo: true

View File

@ -1,7 +1,7 @@
#!/bin/sh #!/bin/sh
# Dump all env vars so we can source them in cron jobs # Dump all env vars so we can source them in cron jobs
printenv | sed 's/^\(.*\)$/export \1/g' > /env.sh rcb dump-env > /env.sh
# Write crontab # Write crontab
rcb crontab > crontab rcb crontab > crontab

View File

@ -1 +1 @@
__version__ = '0.6.0' __version__ = '0.7.1'

View File

@ -51,6 +51,9 @@ def main():
elif args.action == "crontab": elif args.action == "crontab":
crontab(config) crontab(config)
elif args.action == "dump-env":
dump_env()
# Random test stuff here # Random test stuff here
elif args.action == "test": elif args.action == "test":
nodes = utils.get_swarm_nodes() nodes = utils.get_swarm_nodes()
@ -105,10 +108,10 @@ def status(config, containers):
logger.info( logger.info(
' - %s (is_ready=%s) -> %s', ' - %s (is_ready=%s) -> %s',
instance.container_type, instance.container_type,
ping == 0, ping,
instance.backup_destination_path(), instance.backup_destination_path(),
) )
if ping != 0: if not ping:
logger.error("Database '%s' in service %s cannot be reached", logger.error("Database '%s' in service %s cannot be reached",
instance.container_type, container.service_name) instance.container_type, container.service_name)
@ -290,6 +293,14 @@ def crontab(config):
print(cron.generate_crontab(config)) print(cron.generate_crontab(config))
def dump_env():
"""Dump all environment variables to a script that can be sourced from cron"""
print("#!/bin/bash")
print("# This file was generated by restic-compose-backup")
for key, value in os.environ.items():
print("export {}='{}'".format(key, value))
def parse_args(): def parse_args():
parser = argparse.ArgumentParser(prog='restic_compose_backup') parser = argparse.ArgumentParser(prog='restic_compose_backup')
parser.add_argument( parser.add_argument(
@ -303,6 +314,7 @@ def parse_args():
'cleanup', 'cleanup',
'version', 'version',
'crontab', 'crontab',
'dump-env',
'test', 'test',
], ],
) )

View File

@ -1,7 +1,9 @@
import logging import logging
from typing import List, Tuple from typing import List, Tuple, Union
from subprocess import Popen, PIPE from subprocess import Popen, PIPE
from restic_compose_backup import utils
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -9,37 +11,33 @@ def test():
return run(['ls', '/volumes']) return run(['ls', '/volumes'])
def ping_mysql(host, port, username) -> int: def ping_mysql(container_id, host, port, username, password) -> int:
"""Check if the mysql is up and can be reached""" """Check if the mysql is up and can be reached"""
return run([ return docker_exec(container_id, [
'mysqladmin', 'mysqladmin',
'ping', 'ping',
'--host',
host,
'--port',
port,
'--user', '--user',
username, username,
]) ], environment={
'MYSQL_PWD': password
})
def ping_mariadb(host, port, username) -> int: def ping_mariadb(container_id, host, port, username, password) -> int:
"""Check if the mariadb is up and can be reached""" """Check if the mariadb is up and can be reached"""
return run([ return docker_exec(container_id, [
'mysqladmin', 'mysqladmin',
'ping', 'ping',
'--host',
host,
'--port',
port,
'--user', '--user',
username, username,
]) ], environment={
'MYSQL_PWD': password
})
def ping_postgres(host, port, username, password) -> int: def ping_postgres(container_id, host, port, username, password) -> int:
"""Check if postgres can be reached""" """Check if postgres can be reached"""
return run([ return docker_exec(container_id, [
"pg_isready", "pg_isready",
f"--host={host}", f"--host={host}",
f"--port={port}", f"--port={port}",
@ -47,6 +45,22 @@ def ping_postgres(host, port, username, password) -> int:
]) ])
def docker_exec(container_id: str, cmd: List[str], environment: Union[dict, list] = []) -> int:
"""Execute a command within the given container"""
client = utils.docker_client()
logger.debug('docker exec inside %s: %s', container_id, ' '.join(cmd))
exit_code, (stdout, stderr) = client.containers.get(container_id).exec_run(cmd, demux=True, environment=environment)
if stdout:
log_std('stdout', stdout.decode(),
logging.DEBUG if exit_code == 0 else logging.ERROR)
if stderr:
log_std('stderr', stderr.decode(), logging.ERROR)
return exit_code
def run(cmd: List[str]) -> int: def run(cmd: List[str]) -> int:
"""Run a command with parameters""" """Run a command with parameters"""
logger.debug('cmd: %s', ' '.join(cmd)) logger.debug('cmd: %s', ' '.join(cmd))

View File

@ -25,20 +25,19 @@ class MariadbContainer(Container):
"""Check the availability of the service""" """Check the availability of the service"""
creds = self.get_credentials() creds = self.get_credentials()
with utils.environment('MYSQL_PWD', creds['password']): return commands.ping_mariadb(
return commands.ping_mariadb( self.id,
creds['host'], creds['host'],
creds['port'], creds['port'],
creds['username'], creds['username'],
) creds['password']
) == 0
def dump_command(self) -> list: def dump_command(self) -> list:
"""list: create a dump command restic and use to send data through stdin""" """list: create a dump command restic and use to send data through stdin"""
creds = self.get_credentials() creds = self.get_credentials()
return [ return [
"mysqldump", "mysqldump",
f"--host={creds['host']}",
f"--port={creds['port']}",
f"--user={creds['username']}", f"--user={creds['username']}",
"--all-databases", "--all-databases",
"--no-tablespaces", "--no-tablespaces",
@ -48,12 +47,15 @@ class MariadbContainer(Container):
config = Config() config = Config()
creds = self.get_credentials() creds = self.get_credentials()
with utils.environment('MYSQL_PWD', creds['password']): return restic.backup_from_stdin(
return restic.backup_from_stdin( config.repository,
config.repository, self.backup_destination_path(),
self.backup_destination_path(), self.id,
self.dump_command(), self.dump_command(),
) environment={
'MYSQL_PWD': creds['password']
}
)
def backup_destination_path(self) -> str: def backup_destination_path(self) -> str:
destination = Path("/databases") destination = Path("/databases")
@ -85,20 +87,19 @@ class MysqlContainer(Container):
"""Check the availability of the service""" """Check the availability of the service"""
creds = self.get_credentials() creds = self.get_credentials()
with utils.environment('MYSQL_PWD', creds['password']): return commands.ping_mysql(
return commands.ping_mysql( self.id,
creds['host'], creds['host'],
creds['port'], creds['port'],
creds['username'], creds['username'],
) creds['password']
) == 0
def dump_command(self) -> list: def dump_command(self) -> list:
"""list: create a dump command restic and use to send data through stdin""" """list: create a dump command restic and use to send data through stdin"""
creds = self.get_credentials() creds = self.get_credentials()
return [ return [
"mysqldump", "mysqldump",
f"--host={creds['host']}",
f"--port={creds['port']}",
f"--user={creds['username']}", f"--user={creds['username']}",
"--all-databases", "--all-databases",
"--no-tablespaces", "--no-tablespaces",
@ -108,12 +109,15 @@ class MysqlContainer(Container):
config = Config() config = Config()
creds = self.get_credentials() creds = self.get_credentials()
with utils.environment('MYSQL_PWD', creds['password']): return restic.backup_from_stdin(
return restic.backup_from_stdin( config.repository,
config.repository, self.backup_destination_path(),
self.backup_destination_path(), self.id,
self.dump_command(), self.dump_command(),
) environment={
"MYSQL_PWD": creds['password']
}
)
def backup_destination_path(self) -> str: def backup_destination_path(self) -> str:
destination = Path("/databases") destination = Path("/databases")
@ -146,11 +150,12 @@ class PostgresContainer(Container):
"""Check the availability of the service""" """Check the availability of the service"""
creds = self.get_credentials() creds = self.get_credentials()
return commands.ping_postgres( return commands.ping_postgres(
self.id,
creds['host'], creds['host'],
creds['port'], creds['port'],
creds['username'], creds['username'],
creds['password'], creds['password'],
) ) == 0
def dump_command(self) -> list: def dump_command(self) -> list:
"""list: create a dump command restic and use to send data through stdin""" """list: create a dump command restic and use to send data through stdin"""
@ -158,22 +163,19 @@ class PostgresContainer(Container):
creds = self.get_credentials() creds = self.get_credentials()
return [ return [
"pg_dump", "pg_dump",
f"--host={creds['host']}",
f"--port={creds['port']}",
f"--username={creds['username']}", f"--username={creds['username']}",
creds['database'], creds['database'],
] ]
def backup(self): def backup(self):
config = Config() config = Config()
creds = self.get_credentials()
with utils.environment('PGPASSWORD', creds['password']): return restic.backup_from_stdin(
return restic.backup_from_stdin( config.repository,
config.repository, self.backup_destination_path(),
self.backup_destination_path(), self.id,
self.dump_command(), self.dump_command(),
) )
def backup_destination_path(self) -> str: def backup_destination_path(self) -> str:
destination = Path("/databases") destination = Path("/databases")

View File

@ -2,9 +2,10 @@
Restic commands Restic commands
""" """
import logging import logging
from typing import List, Tuple from typing import List, Tuple, Union
from subprocess import Popen, PIPE from subprocess import Popen, PIPE
from restic_compose_backup import commands from restic_compose_backup import commands
from restic_compose_backup import utils
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -27,9 +28,10 @@ def backup_files(repository: str, source='/volumes'):
])) ]))
def backup_from_stdin(repository: str, filename: str, source_command: List[str]): def backup_from_stdin(repository: str, filename: str, container_id: str,
source_command: List[str], environment: Union[dict, list] = None):
""" """
Backs up from stdin running the source_command passed in. Backs up from stdin running the source_command passed in within the given container.
It will appear in restic with the filename (including path) passed in. It will appear in restic with the filename (including path) passed in.
""" """
dest_command = restic(repository, [ dest_command = restic(repository, [
@ -39,20 +41,43 @@ def backup_from_stdin(repository: str, filename: str, source_command: List[str])
filename, filename,
]) ])
# pipe source command into dest command client = utils.docker_client()
source_process = Popen(source_command, stdout=PIPE, bufsize=65536)
dest_process = Popen(dest_command, stdin=source_process.stdout, stdout=PIPE, stderr=PIPE, bufsize=65536) logger.debug('docker exec inside %s: %s', container_id, ' '.join(source_command))
# Create and start source command inside the given container
handle = client.api.exec_create(container_id, source_command, environment=environment)
exec_id = handle["Id"]
stream = client.api.exec_start(exec_id, stream=True, demux=True)
source_stderr = ""
# Create the restic process to receive the output of the source command
dest_process = Popen(dest_command, stdin=PIPE, stdout=PIPE, stderr=PIPE, bufsize=65536)
# Send the ouptut of the source command over to restic in the chunks received
for stdout_chunk, stderr_chunk in stream:
if stdout_chunk:
dest_process.stdin.write(stdout_chunk)
if stderr_chunk:
source_stderr += stderr_chunk.decode()
# Wait for restic to finish
stdout, stderr = dest_process.communicate() stdout, stderr = dest_process.communicate()
# Ensure both processes exited with code 0 # Ensure both processes exited with code 0
source_exit, dest_exit = source_process.poll(), dest_process.poll() source_exit = client.api.exec_inspect(exec_id).get("ExitCode")
exit_code = 0 if (source_exit == 0 and dest_exit == 0) else 1 dest_exit = dest_process.poll()
exit_code = source_exit or dest_exit
if stdout: if stdout:
commands.log_std('stdout', stdout, logging.DEBUG if exit_code == 0 else logging.ERROR) commands.log_std('stdout', stdout, logging.DEBUG if exit_code == 0 else logging.ERROR)
if source_stderr:
commands.log_std(f'stderr ({source_command[0]})', source_stderr, logging.ERROR)
if stderr: if stderr:
commands.log_std('stderr', stderr, logging.ERROR) commands.log_std('stderr (restic)', stderr, logging.ERROR)
return exit_code return exit_code

View File

@ -3,6 +3,7 @@ import logging
from typing import List, TYPE_CHECKING from typing import List, TYPE_CHECKING
from contextlib import contextmanager from contextlib import contextmanager
import docker import docker
from docker import DockerClient
if TYPE_CHECKING: if TYPE_CHECKING:
from restic_compose_backup.containers import Container from restic_compose_backup.containers import Container
@ -12,7 +13,7 @@ logger = logging.getLogger(__name__)
TRUE_VALUES = ['1', 'true', 'True', True, 1] TRUE_VALUES = ['1', 'true', 'True', True, 1]
def docker_client(): def docker_client() -> DockerClient:
""" """
Create a docker client from the following environment variables:: Create a docker client from the following environment variables::

View File

@ -3,7 +3,7 @@ from setuptools import setup, find_namespace_packages
setup( setup(
name="restic-compose-backup", name="restic-compose-backup",
url="https://github.com/ZettaIO/restic-compose-backup", url="https://github.com/ZettaIO/restic-compose-backup",
version="0.7.0", version="0.7.1",
author="Einar Forselv", author="Einar Forselv",
author_email="eforselv@gmail.com", author_email="eforselv@gmail.com",
packages=find_namespace_packages(include=[ packages=find_namespace_packages(include=[

View File

@ -53,4 +53,4 @@ norecursedirs = tests/* .venv/* .tox/* build/ docs/
ignore = H405,D100,D101,D102,D103,D104,D105,D200,D202,D203,D204,D205,D211,D301,D400,D401,W503 ignore = H405,D100,D101,D102,D103,D104,D105,D200,D202,D203,D204,D205,D211,D301,D400,D401,W503
show-source = True show-source = True
max-line-length = 120 max-line-length = 120
exclude = .tox,env,tests,build,conf.py exclude = .tox,env,tests,build,conf.py,.venv